-
Notifications
You must be signed in to change notification settings - Fork 686
Expand file tree
/
Copy pathwindow_filter.lua
More file actions
2325 lines (2148 loc) · 103 KB
/
window_filter.lua
File metadata and controls
2325 lines (2148 loc) · 103 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
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--- === hs.window.filter ===
---
--- Filter windows by application, title, location on screen and more, and easily subscribe to events on these windows
---
--- Warning: this module is still somewhat experimental.
--- Should you encounter any issues, please feel free to report them on https://github.com/Hammerspoon/hammerspoon/issues
--- or #hammerspoon on irc.libera.chat.
---
--- Windowfilters monitor all windows as they're created, closed, moved etc., and select some (or none) among these windows
--- according to specific filtering rules. These filtering rules are app-specific, i.e. they start off by selecting all windows
--- belonging to a certain application (but you can also define *default* and *override* filters - see `:setAppFilter()`,
--- `:setDefaultFilter()`, `:setOverrideFilter()`) and they can allow or reject windows based on:
--- * visibility, focused and/or fullscreen status
--- * title length or patterns in the title
--- * position on screen (inside or outside a certain region or screen)
--- * accessibility role (standard window, dialog, etc.)
--- * whether they're in the current Mission Control Space or not
---
--- The filtering happens automatically in the background; windowfilters then:
--- * generate a dynamic list of the windows that currently satisfy the filtering rules (see `:getWindows()`)
--- * sanitize and expose all pertinent events on these windows (see `:subscribe()` and the module constants with all the events)
---
--- A *default windowfilter* (not to be confused with the default filter *within* a windowfilter) is provided as convenience;
--- it excludes some known apps and windows that are transient in nature, therefore unlikely to be "interesting" for e.g. window management.
--- `hs.window.filter.new()` (with no arguments) returns a copy of the default windowfilter that you can further tailor
--- to your needs - see `hs.window.filter.default` and `hs.window.filter.new()` for more information.
---
--- Usage examples:
--- ```
--- local wf=hs.window.filter
---
--- -- alter the default windowfilter
--- wf.default:setAppFilter('My IDE',{allowTitles=1}) -- ignore no-title windows (e.g. transient autocomplete suggestions) in My IDE
---
--- -- set the exact scope of what you're interested in - see hs.window.filter:setAppFilter()
--- wf_terminal = wf.new{'Terminal','iTerm2'} -- all visible terminal windows
--- wf_timewaster = wf.new(false):setAppFilter('Safari',{allowTitles='reddit'}) -- any Safari windows with "reddit" anywhere in the title
--- wf_leftscreen = wf.new{override={visible=true,fullscreen=false,allowScreens='-1,0',currentSpace=true}}
--- -- all visible and non-fullscreen windows that are on the screen to the left of the primary screen in the current Space
--- wf_editors_righthalf = wf.new{'TextEdit','Sublime Text','BBEdit'}:setRegions(hs.screen.primaryScreen():fromUnitRect'0.5,0/1,1')
--- -- text editor windows that are on the right half of the primary screen
--- wf_bigwindows = wf.new(function(w)return w:frame().area>3000000 end) -- only very large windows
--- wf_notif = wf.new{['Notification Center']={allowRoles='AXNotificationCenterAlert'}} -- notification center alerts
---
--- -- subscribe to events
--- wf_terminal:subscribe(wf.windowFocused,some_fn) -- run a function whenever a terminal window is focused
--- wf_timewaster:subscribe(wf.hasWindow,startAnnoyingMe):subscribe(wf.hasNoWindows,stopAnnoyingMe) -- fight procrastination :)
--- ```
-- The pure filtering part alone should fulfill a lot of use cases
-- * The root and default filters should be quite handy for users; the user is able to customize both, but ideally
-- there should be ongoing maintenance on the list by the core maintainers
-- * Maybe an additional filter could be added for window geometry (e.g. minimum width/height/area)
-- The 'active' part abstracts hs.application.watcher and hs.uielement.watcher into a simple and coherent API
-- for users who are interested in window events. Additionally, a lot of effort is spent on cleaning up
-- the mess coming from osx events:
-- * redundant events are never fired more than once
-- * related events are fired in the correct order (e.g. the previous window is unfocused before the
-- current one is focused)
-- * 'missing' events are filled in (e.g. a focused window that gets destroyed for any reason emits unfocused first)
-- * coherency is maintained (e.g. closing System Preferences with cmd-w has the same result as with cmd-q)
-- A further :notify() method is provided for use cases with highly specific filters.
--
-- * There is the usual problem with spaces; it's usefully abstracted away from userspace via .currentSpace field in filters,
-- but the implementation is inefficient as it relies on calling hs.window.allWindows() (which can be slow)
-- on space changes.
-- * window(un)maximized could be implemented, or merged into window(un)fullscreened (but currently isn't either)
local pairs,ipairs,type,smatch,sformat,ssub = pairs,ipairs,type,string.match,string.format,string.sub
local next,tsort,setmetatable,pcall = next,table.sort,setmetatable,pcall
local timer,geometry,screen = require'hs.timer',require'hs.geometry',require'hs.screen'
local application,window = require'hs.application',hs.window
local appwatcher,uiwatcher = application.watcher,require'hs.uielement'.watcher
local axuielement, fnutils = require("hs.axuielement"), require("hs.fnutils")
local logger = require'hs.logger'
local log = logger.new('wfilter')
local DISTANT_FUTURE=315360000 -- 10 years (roughly)
local windowMT = hs.getObjectMetatable("hs.window")
local windowfilter={} -- module
local WF={} -- class
-- instance fields:
-- .filters = filters set
-- .events = subscribed events
-- .windows = current allowed windows
-- .pending = windows that must still emit more events in an event chain - cleared when the last event of the chain has been emitted
local global = {} -- global state (focused app, focused window, appwatchers running or not)
local activeInstances = {} -- active wf instances (i.e. with subscriptions or :keepActive)
local spacesInstances = {} -- wf instances that also need to be "active" because they care about Spaces
local screensInstances = {} -- wf instances that care about screens (needn't be active, but must screen.watcher)
local applicationInstances = {} -- wf instances that care about the active application
local applicationActiveInstances = {} -- wf instances above that are also active
local pendingApps = {} -- apps (hopefully temporarily) resisting being watched (hs.application)
local apps = {} -- all GUI apps (class App) containing all windows (class Window)
local App,Window={},{} -- classes
local preexistingWindowFocused,preexistingWindowCreated={},{} -- used to 'bootstrap' fields .focused/.created and preserve relative ordering in :getWindows
--- hs.window.filter.ignoreAlways
--- Variable
--- A table of application names (as per `hs.application:name()`) that are always ignored by this module.
--- These are apps with no windows or any visible GUI, such as system services, background daemons and "helper" apps.
---
--- You can add an app to this table with `hs.window.filter.ignoreAlways['Background App Title'] = true`
---
--- Notes:
--- * As the name implies, even the empty, "allow all" windowfilter will ignore these apps.
--- * You don't *need* to keep this table up to date, since non GUI apps will simply never show up anywhere; this table is just used as a "root" filter to gain a (very small) performance improvement.
do
local SKIP_APPS_NO_PID = {
-- ideally, keep this updated (used in the root filter)
-- these will be shown as a warning in the console ("No accessibility access to app ...")
'universalaccessd','sharingd','Safari Networking', 'Spotlight Networking', 'iTunes Helper','Safari Web Content',
'App Store Web Content', 'Safari Database Storage',
'Google Chrome Helper','Spotify Helper',
'Todoist Networking', 'Safari Storage', 'Todoist Database Storage', 'AAM Updates Notifier', 'Slack Helper',
-- 'Little Snitch Agent','Little Snitch Network Monitor', -- depends on security settings in Little Snitch
}
local SKIP_APPS_NO_WINDOWS = {
-- ideally, keep this updated (used in the root filter)
-- hs.window.filter._showCandidates() -- from the console
'com.apple.internetaccounts', 'CoreServicesUIAgent', 'AirPlayUIAgent',
'com.apple.security.pboxd', 'PowerChime',
'SystemUIServer', 'Dock', 'com.apple.dock.extra', 'storeuid',
'Folder Actions Dispatcher', 'Keychain Circle Notification', 'Wi-Fi',
'Image Capture Extension', 'iCloud Photos', 'System Events',
'Speech Synthesis Server', 'Dropbox Finder Integration', 'LaterAgent',
'Karabiner_AXNotifier', 'Photos Agent', 'EscrowSecurityAlert',
'Google Chrome Helper', 'com.apple.MailServiceAgent', 'Safari Web Content', 'Mail Web Content',
'Safari Networking', 'nbagent','rcd',
'Evernote Helper', 'BTTRelaunch',
--'universalAccessAuthWarn', -- actual window "App.app would like to control this computer..."
}
windowfilter.ignoreAlways = {}
for _,list in ipairs{SKIP_APPS_NO_PID,SKIP_APPS_NO_WINDOWS} do
for _,appname in ipairs(list) do windowfilter.ignoreAlways[appname] = true end
end
local SKIP_APPS_TRANSIENT_WINDOWS = {
--TODO keep this updated (used in the default filter)
-- hs.window.filter._showCandidates() -- from the console
'Spotlight', 'Notification Center', 'loginwindow', 'ScreenSaverEngine', 'PressAndHold',
-- preferences etc
'PopClip','Isolator', 'CheatSheet', 'CornerClickBG', 'Alfred 2', 'Moom', 'CursorSense Manager',
-- menulets
'Music Manager', 'Google Drive', 'Dropbox', '1Password mini', 'Colors for Hue', 'MacID',
'CrashPlan menu bar', 'Flux', 'Jettison', 'Bartender', 'SystemPal', 'BetterSnapTool', 'Grandview', 'Radium',
'MenuMetersApp', 'DemoPro',
}
windowfilter.ignoreInDefaultFilter = {}
for _,appname in ipairs(SKIP_APPS_TRANSIENT_WINDOWS) do windowfilter.ignoreInDefaultFilter[appname] = true end
end
-- utility function for maintainers; shows (in the console) candidate apps that, if recognized as
-- "no GUI" or "transient window" apps, can be added to the relevant tables for the default windowfilter
function windowfilter._showCandidates()
local running=application.runningApplications()
local t={}
for _,app in ipairs(running) do
local appname = app:name()
local pid = app:pid()
if appname and windowfilter.isGuiApp(appname) and #app:allWindows()==0
and not windowfilter.ignoreInDefaultFilter[appname] and app:kind()>=0
and (not apps[pid] or not next(apps[pid].windows)) then
t[#t+1]=appname
end
end
print(require'hs.inspect'(t))
end
--- hs.window.filter.allowedWindowRoles
--- Variable
--- A table for window roles (as per `hs.window:subrole()`) that are allowed by default.
---
--- Set the desired window roles as *keys* in this table, like this: `hs.window.filter.allowedWindowRoles = {AXStandardWindow=true,AXDialog=true}`
---
--- Notes:
--- * You can have fine grained control of allowed window roles via the `setAppFilter`, `setDefaultFilter`, `setOverrideFilter` methods.
--- * If you know what you're doing you can override the allowed window roles globally by changing this variable, but this is discouraged.
windowfilter.allowedWindowRoles = {['AXStandardWindow']=true,['AXDialog']=true,['AXSystemDialog']=true}
--- hs.window.filter:isWindowAllowed(window) -> boolean
--- Method
--- Checks if a window is allowed by the windowfilter
---
--- Parameters:
--- * window - an `hs.window` object to check
---
--- Returns:
--- * `true` if the window is allowed by the windowfilter, `false` otherwise; `nil` if an invalid object was passed
local function matchTitles(titles,t)
for _,title in ipairs(titles) do
if smatch(t,title) then return true end
end
end
local function matchRegions(regions,frame) -- if more than half the window is inside, or if more than half the region is covered
for _,region in ipairs(regions) do
local area=frame:intersect(region).area
if area>0 and (area>frame.area*0.5 or area>region.area*0.5) then return true end
end
end
local function checkWindowAllowed(filter,win)
if filter.visible~=nil and filter.visible~=win.isVisible then return false,'visible' end
if filter.currentSpace~=nil and filter.currentSpace~=win.isInCurrentSpace then return false,'currentSpace' end
if filter.allowTitles then
if type(filter.allowTitles)=='number' then if #win.title<=filter.allowTitles then return false,'allowTitles' end
elseif not matchTitles(filter.allowTitles,win.title) then return false,'allowTitles' end
end
if filter.rejectTitles and matchTitles(filter.rejectTitles,win.title) then return false,'rejectTitles' end
if filter.fullscreen~=nil and filter.fullscreen~=win.isFullscreen then return false,'fullscreen' end
if filter.focused~=nil and filter.focused~=(win==global.focused) then return false,'focused' end
if filter.activeApplication~=nil and filter.activeApplication~=(global.active==win.app) then return false,'activeApplication' end
if win.isVisible then --min and hidden disregard regions and screens
if filter.allowRegions and not matchRegions(filter.allowRegions,win.frame) then return false,'allowRegions' end
if filter.rejectRegions and matchRegions(filter.allowRegions,win.frame) then return false,'rejectRegions' end
if filter.allowScreens and not filter._allowedScreens[win.screen] then return false,'allowScreens' end
if filter.rejectScreens and filter._rejectedScreens[win.screen] then return false,'rejectScreens' end
end
if filter.hasTitlebar~=nil and win.hasTitlebar~=filter.hasTitlebar then return false,'hasTitlebar' end
local approles = filter.allowRoles or windowfilter.allowedWindowRoles
if approles~='*' and not approles[win.role] then return false,'allowRoles' end
return true,''
end
local shortRoles={AXStandardWindow='wnd',AXDialog='dlg',AXSystemDialog='sys dlg',AXFloatingWindow='float',
AXNotificationCenterBanner='notif',AXUnknown='unknown',['']='no role'}
local function isWindowAllowed(self,win)
local role,appname,id=shortRoles[win.role] or win.role,win.app.name,win.id
local filter=self.filters.override
if filter==false then self.log.vf('REJECT %s (%s %d): override filter reject',appname,role,id) return false
elseif filter then
local r,cause=checkWindowAllowed(filter,win)
if not r then
self.log.vf('REJECT %s (%s %d): override filter [%s]',appname,role,id,cause)
return r
end
end
if not windowfilter.isGuiApp(appname) then
--if you see this in the log, add to .ignoreAlways
self.log.wf('REJECT %s (%s %d): should be a non-GUI app!',appname,role,id) return false
end
filter=self.filters[appname]
if filter==false then self.log.vf('REJECT %s (%s %d): app filter reject',appname,role,id) return false
elseif filter then
local r,cause=checkWindowAllowed(filter,win)
self.log.vf('%s %s (%s %d): app filter [%s]',r and 'ALLOW' or 'REJECT',appname,role,id,cause)
return r
end
filter=self.filters.default
if filter==false then self.log.vf('REJECT %s (%s %d): default filter reject',appname,role,id) return false
elseif filter then
local r,cause=checkWindowAllowed(filter,win)
self.log.vf('%s %s (%s %d): default filter [%s]',r and 'ALLOW' or 'REJECT',appname,role,id,cause)
return r
end
self.log.vf('ALLOW %s (%s %d) (no filter)',appname,role,id)
return true
end
function WF:isWindowAllowed(theWindow)
if not theWindow then return end
local id=theWindow.id and theWindow:id()
--this filters out non-windows, as well as AXScrollArea from Finder (i.e. the desktop)
--which allegedly is a window, but without id
if not id then return end
if activeInstances[self] then return self.windows[id] and true or false end
local pid,win=theWindow:application():pid()
if apps[pid] then
for wid,w in pairs(apps[pid].windows) do
if wid==id then win=w break end
end
end
if not win then
-- hs.assert(not global.watcher,'window not being tracked')
self.log.d('window is not being tracked')
win=Window.new(theWindow,id) --fixme
win.app={} win.app.name=theWindow:application():name()
if self.trackSpacesFilters then
win.isInCurrentSpace=false
if not win.isVisible then win.isInCurrentSpace=true
else
local allwins=theWindow:application():visibleWindows()
for _,w in ipairs(allwins) do
if w:id()==id then win.isInCurrentSpace=true break end
end
end
end
if not global.watcher then
--temporarily fill in the necessary data
local frontapp = application.frontmostApplication()
local frontwin = frontapp and frontapp:focusedWindow()
if frontwin and frontwin:id()==id then global.focused=win else global.focused=nil end
if frontapp:pid()==theWindow:application():pid() then global.active=win.app else global.active=nil end
end
end
return isWindowAllowed(self,win)
end
--- hs.window.filter:isAppAllowed(appname) -> boolean
--- Method
--- Checks if an app is allowed by the windowfilter
---
--- Parameters:
--- * appname - app name as per `hs.application:name()`
---
--- Returns:
--- * `false` if the app is rejected by the windowfilter; `true` otherwise
function WF:isAppAllowed(appname)
return windowfilter.isGuiApp(appname) and self.filters[appname]~=false
end
--- hs.window.filter:rejectApp(appname) -> hs.window.filter object
--- Method
--- Sets the windowfilter to outright reject any windows belonging to a specific app
---
--- Parameters:
--- * appname - app name as per `hs.application:name()`
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
---
--- Notes:
--- * this is just a convenience wrapper for `windowfilter:setAppFilter(appname,false)`
function WF:rejectApp(appname)
return self:setAppFilter(appname,false)
end
--- hs.window.filter:allowApp(appname) -> hs.window.filter object
--- Method
--- Sets the windowfilter to allow all visible windows belonging to a specific app
---
--- Parameters:
--- * appname - app name as per `hs.application:name()`
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
---
--- Notes:
--- * this is just a convenience wrapper for `windowfilter:setAppFilter(appname,{visible=true})`
function WF:allowApp(appname)
return self:setAppFilter(appname,true)--nil,nil,windowfilter.allowedWindowRoles,nil,true)
end
--- hs.window.filter:setDefaultFilter(filter) -> hs.window.filter object
--- Method
--- Set the default filtering rules to be used for apps without app-specific rules
---
--- Parameters:
--- * filter - see `hs.window.filter:setAppFilter`
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
function WF:setDefaultFilter(...)
return self:setAppFilter('default',...)
end
--- hs.window.filter:setOverrideFilter(filter) -> hs.window.filter object
--- Method
--- Set overriding filtering rules that will be applied for all apps before any app-specific rules
---
--- Parameters:
--- * filter - see `hs.window.filter:setAppFilter`
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
function WF:setOverrideFilter(...)
return self:setAppFilter('override',...)
end
--- hs.window.filter:setCurrentSpace(val) -> hs.window.filter object
--- Method
--- Sets whether the windowfilter should only allow (or reject) windows in the current Mission Control Space
---
--- Parameters:
--- * val - boolean; if `true`, only allow windows in the current Mission Control Space, plus minimized and hidden windows;
--- if `false`, reject them; if `nil`, ignore Mission Control Spaces
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
---
--- Notes:
--- * This is just a convenience wrapper for setting the `currentSpace` field in the `override` filter (other
--- fields will be left untouched); per-app filters will maintain their `currentSpace` field, if present, as is
--- * Spaces-aware windowfilters might experience a (sometimes significant) delay after every Space switch, since
--- (due to OS X limitations) they must re-query for the list of all windows in the current Space every time.
function WF:setCurrentSpace(val)
local nf=self.filters.override or {}
if nf~=false then nf.currentSpace=val end
return self:setOverrideFilter(nf)
end
--- hs.window.filter:setRegions(regions) -> hs.window.filter object
--- Method
--- Sets the allowed screen regions for this windowfilter
---
--- Parameters:
--- * regions - an `hs.geometry` rect or constructor argument, or a list of them, indicating the allowed region(s) for this windowfilter
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
---
--- Notes:
--- * This is just a convenience wrapper for setting the `allowRegions` field in the `override` filter (other fields will be left untouched); per-app filters will maintain their `allowRegions` and `rejectRegions` fields, if present
function WF:setRegions(val)
local nf=self.filters.override or {}
if nf~=false then nf.allowRegions=val end
return self:setOverrideFilter(nf)
end
--- hs.window.filter:setScreens(screens) -> hs.window.filter object
--- Method
--- Sets the allowed screens for this windowfilter
---
--- Parameters:
--- * regions - a valid argument for `hs.screen.find()`, or a list of them, indicating the allowed screen(s) for this windowfilter
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
---
--- Notes:
--- * This is just a convenience wrapper for setting the `allowScreens` field in the `override` filter (other
--- fields will be left untouched); per-app filters will maintain their `allowScreens` and `rejectScreens` fields, if present
function WF:setScreens(val)
local nf=self.filters.override or {}
if nf~=false then nf.allowScreens=val end
return self:setOverrideFilter(nf)
end
--- hs.window.filter:setAppFilter(appname, filter) -> hs.window.filter object
--- Method
--- Sets the detailed filtering rules for the windows of a specific app
---
--- Parameters:
--- * appname - app name as per `hs.application:name()`
--- * filter - if `false`, reject the app; if `true`, `nil`, or omitted, allow all visible windows (in any Space) for the app; otherwise it must be a table describing the filtering rules for the app, via the following fields:
--- * visible - if `true`, only allow visible windows (in any Space); if `false`, reject visible windows; if omitted, this rule is ignored
--- * currentSpace - if `true`, only allow windows in the current Mission Control Space (minimized and hidden windows are included, as they're considered to belong to all Spaces); if `false`, reject windows in the current Space (including all minimized and hidden windows); if omitted, this rule is ignored
--- * fullscreen - if `true`, only allow fullscreen windows; if `false`, reject fullscreen windows; if omitted, this rule is ignored
--- * hasTitlebar - if `true`, only allow windows with titlebar; if `false`, reject window with titlebar; if omitted, this rule is ignored
--- * focused - if `true`, only allow a window while focused; if `false`, reject the focused window; if omitted, this rule is ignored
--- * activeApplication - only allow any of this app's windows while it is (if `true`) or it's not (if `false`) the active application; if omitted, this rule is ignored
--- * allowTitles
--- * if a number, only allow windows whose title is at least as many characters long; e.g. pass `1` to filter windows with an empty title
--- * if a string or table of strings, only allow windows whose title matches (one of) the pattern(s) as per `string.match`
--- * if omitted, this rule is ignored
--- * rejectTitles - if a string or table of strings, reject windows whose titles matches (one of) the pattern(s) as per `string.match`; if omitted, this rule is ignored
--- * allowRegions - an `hs.geometry` rect or constructor argument, or a list of them, designating (a) screen "region(s)" in absolute coordinates: only allow windows that "cover" at least 50% of (one of) the region(s), and/or windows that have at least 50% of their surface inside (one of) the region(s); if omitted, this rule is ignored
--- * rejectRegions - an `hs.geometry` rect or constructor argument, or a list of them, designating (a) screen "region(s)" in absolute coordinates: reject windows that "cover" at least 50% of (one of) the region(s), and/or windows that have at least 50% of their surface inside (one of) the region(s); if omitted, this rule is ignored
--- * allowScreens - a valid argument for `hs.screen.find()`, or a list of them, indicating one (or more) screen(s): only allow windows that (mostly) lie on (one of) the screen(s); if omitted, this rule is ignored
--- * rejectScreens - a valid argument for `hs.screen.find()`, or a list of them, indicating one (or more) screen(s): reject windows that (mostly) lie on (one of) the screen(s); if omitted, this rule is ignored
--- * allowRoles
--- * if a string or table of strings, only allow these window roles as per `hs.window:subrole()`
--- * if the special string `'*'`, this rule is ignored (i.e. all window roles, including empty ones, are allowed)
--- * if omitted, use the default allowed roles (defined in `hs.window.filter.allowedWindowRoles`)
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
---
--- Notes:
--- * Passing `focused=true` in `filter` will (naturally) result in the windowfilter ever allowing 1 window at most
--- * If you want to allow *all* windows for an app, including invisible ones, pass an empty table for `filter`
--- * Spaces-aware windowfilters might experience a (sometimes significant) delay after every Space switch, since (due to OS X limitations) they must re-query for the list of all windows in the current Space every time.
--- * If System Preferences>Mission Control>Displays have separate Spaces is *on*, the *current Space* is defined as the union of all the Spaces that are currently visible
--- * This table explains the effects of different combinations of `visible` and `currentSpace`, showing which windows will be allowed:
--- ```
--- |visible= nil | true | false |
--- |currentSpace|------------------------------------------|------------------------------|--------------|
--- | nil |all |visible in ANY space |min and hidden|
--- | true |visible in CURRENT space+min and hidden |visible in CURRENT space |min and hidden|
--- | false |visible in OTHER space only+min and hidden|visible in OTHER space only |none |
--- ```
local refreshWindows,checkTrackSpacesFilters,checkScreensFilters,checkActiveApplicationFilters
local function getListOfStrings(l)
if type(l)~='table' then return end
local r={}
for _,v in ipairs(l) do if type(v)=='string' then r[#r+1]=v else return end end
return r
end
local function getListOfRects(l)
local ok,res=nil,pcall(geometry.new,l)
if ok and geometry.type(res)=='rect' then l={res} end
if type(l)~='table' then return end
local r={}
for _,v in ipairs(l) do
ok,res=pcall(geometry.new,v)
if ok and geometry.type(res)=='rect' then r[#r+1]=v else return end
end
return r
end
local function getListOfScreens(l)
if type(l)=='number' or type(l)=='string' then l={l}
elseif type(l)=='table' then
local ok,res=pcall(geometry.new,l)
if ok and (geometry.type(res)=='rect' or geometry.type(res)=='size') then l={res} end
end
if type(l)~='table' then return end
local r={}
for _,v in ipairs(l) do
if type(v)=='number' or type(v)=='string' then r[#r+1]=v
elseif type(v)=='table' then
local ok,res=pcall(geometry.new,v)
if ok and (geometry.type(res)=='rect' or geometry.type(res)=='size') then r[#r+1]=res end
end
end
return r
end
--TODO add size/aspect filters?
function WF:setAppFilter(appname,ft,batch)
if type(appname)~='string' then error('appname must be a string',2) end
local logs
if appname=='override' or appname=='default' then logs=sformat('setting %s filter: ',appname)
else logs=sformat('setting filter for %s: ',appname) end
if ft==false then
logs=logs..'reject'
self.filters[appname]=false
else
if ft==nil or ft==true then ft={visible=true} end -- shortcut
if type(ft)~='table' then error('filter must be a table',2) end
local filter = {} -- always override
for k,v in pairs(ft) do
if k=='allowTitles' then
local r
if type(v)=='string' then r={v}
elseif type(v)=='number' then r=v
else r=getListOfStrings(v) end
if not r then error('allowTitles must be a number, string or list of strings',2) end
if type(r)=='table' then
local first=r[1] if #r>1 then first=first..',...' end
logs=sformat('%s%s={%s}, ',logs,k,first)
else logs=sformat('%s%s=%s, ',logs,k,r) end
filter.allowTitles=r
elseif k=='rejectTitles' then
local r
if type(v)=='string' then r={v}
else r=getListOfStrings(v) end
if not r then error('rejectTitles must be a number, string or list of strings',2) end
local first=r[1] if #r>1 then first=first..',...' end
logs=sformat('%s%s={%s}, ',logs,k,first)
filter.rejectTitles=r
elseif k=='allowRoles' then
local r={}
if v=='*' then r=v
elseif type(v)=='string' then r={[v]=true}
elseif type(v)=='table' then
for rk,rv in pairs(v) do
if type(rk)=='number' and type(rv)=='string' then r[rv]=true
elseif type(rk)=='string' and rv then r[rk]=true
else error('incorrect format for allowRoles table',2) end
end
else error('allowRoles must be a string or a list or set of strings',2) end
if type(r)=='table' then
local first=next(r) if next(r,first) then first=first..',...' end
logs=sformat('%s%s={%s}, ',logs,k,first)
else logs=sformat('%s%s=%s, ',logs,k,v) end
filter.allowRoles=r
elseif k=='visible' or k=='fullscreen' or k=='focused' or k=='currentSpace' or k=='activeApplication' or k=='hasTitlebar' then
if type(v)~='boolean' then error(k..' must be a boolean',2) end
filter[k]=v logs=sformat('%s%s=%s, ',logs,k,ft[k])
elseif k=='allowRegions' or k=='rejectRegions' then
local r=getListOfRects(v)
if not r then error(k..' must be an hs.geometry object or constructor, or a list of them',2) end
local first=r[1].string if #r>1 then first=first..',...' end
logs=sformat('%s%s={%s}, ',logs,k,first)
filter[k]=r
elseif k=='allowScreens' or k=='rejectScreens' then
local r=getListOfScreens(v)
if not r then error(k..' must be a valid argument for hs.screen.find, or a list of them',2) end
local first=r[1] if #r>1 then first=first..',...' end
logs=sformat('%s%s={%s}, ',logs,k,first)
filter[k]=r
self.screensFilters=42 --make sure to always re-applyScreenFilters()
else
error('invalid key in filter table: '..tostring(k),2)
end
end
self.filters[appname]=filter
end
self.log.i(logs)
if not batch then
checkTrackSpacesFilters(self) checkScreensFilters(self) checkActiveApplicationFilters(self)
if activeInstances[self] or spacesInstances[self] then return refreshWindows(self) end
end
return self
end
--- hs.window.filter:setFilters(filters) -> hs.window.filter object
--- Method
--- Sets multiple filtering rules
---
--- Parameters:
--- * filters - table, every element will set an application filter; these elements must:
--- - have a *key* of type string, denoting an application name as per `hs.application:name()`
--- - if the *value* is a boolean, the app will be allowed or rejected accordingly - see `hs.window.filter:allowApp()`
--- and `hs.window.filter:rejectApp()`
--- - if the *value* is a table, it must contain the accept/reject rules for the app *as key/value pairs*; valid keys
--- and values are described in `hs.window.filter:setAppFilter()`
--- - the key can be one of the special strings `"default"` and `"override"`, which will set the default and override
--- filter respectively
--- - the key can be the special string `"sortOrder"`; the value must be one of the `sortBy...` constants as per
--- `hs.window.filter:setSortOrder()`
---
--- Returns:
--- * the `hs.window.filter` object for method chaining
---
--- Notes:
--- * every filter definition in `filters` will overwrite the preexisting one for the relevant application, if present;
--- this also applies to the special default and override filters, if included
function WF:setFilters(filters)
if type(filters)~='table' then error('filters must be a table',2) end
for k,v in pairs(filters) do
if type(k)=='number' then
if type(v)=='string' then self:allowApp(v) -- {'appname'}
else error('invalid filters table: integer key '..k..' needs a string value, got '..type(v)..' instead',2) end
elseif type(k)=='string' then --{appname=...}
if k=='sortOrder' then self:setSortOrder(v)
elseif type(v)=='boolean' then if v then self:allowApp(k) else self:rejectApp(k) end --{appname=true/false}
elseif type(v)=='table' then self:setAppFilter(k,v,true) --{appname={arg1=val1,...}}
else error('invalid filters table: key "'..k..'" needs a table value, got '..type(v)..' instead',2) end
else error('invalid filters table: keys can be integer or string, got '..type(k)..' instead',2) end
end
checkTrackSpacesFilters(self) checkScreensFilters(self) checkActiveApplicationFilters(self)
if activeInstances[self] or spacesInstances[self] then return refreshWindows(self) end
return self
end
--- hs.window.filter:getFilters() -> table
--- Method
--- Return a table with all the filtering rules defined for this windowfilter
---
--- Parameters:
--- * None
---
--- Returns:
--- * a table containing the filtering rules of this windowfilter; you can pass this table (optionally
--- after performing valid manipulations) to `hs.window.filter:setFilters()` and `hs.window.filter.new()`
function WF:getFilters()
local r={}
for appname,flt in pairs(self.filters) do
if type(flt)~='table' then r[appname]=flt
else r[appname]={}
for k,v in pairs(flt) do
if k:sub(1,1)~='_' then r[appname][k]=v end
end
end
end
return r
end
--TODO windowstarted/stoppedmoving event? (needs eventtap on mouse and keyboard mods, and hooking up with animations in hs.window,
-- and even then not fully reliable)
local getmetatable,tostring,gsub=getmetatable,tostring,string.gsub
local function __tostring(self) return sformat('hs.window.filter: %s (%s)',self.logname or '...',self.__address) end
function windowfilter.iswf(t)
local mt=getmetatable(t) return mt and mt.__index==WF or false
end
--- hs.window.filter.new(fn[,logname[,loglevel]]) -> hs.window.filter object
--- Constructor
--- Creates a new hs.window.filter instance
---
--- Parameters:
--- * fn
--- * if `nil`, returns a copy of the default windowfilter, including any customizations you might have applied to it
--- so far; you can then further restrict or expand it
--- * if `true`, returns an empty windowfilter that allows every window
--- * if `false`, returns a windowfilter with a default rule to reject every window
--- * if a string or table of strings, returns a windowfilter that only allows visible windows of the specified apps
--- as per `hs.application:name()`
--- * if a table, you can fully define a windowfilter without having to call any methods after construction; the
--- table must be structured as per `hs.window.filter:setFilters()`; if not specified in the table, the
--- default filter in the new windowfilter will reject all windows
--- * otherwise it must be a function that accepts an `hs.window` object and returns `true` if the window is allowed
--- or `false` otherwise; this way you can define a fully custom windowfilter
--- * logname - (optional) name of the `hs.logger` instance for the new windowfilter; if omitted, the class logger will be used
--- * loglevel - (optional) log level for the `hs.logger` instance for the new windowfilter
---
--- Returns:
--- * a new windowfilter instance
function windowfilter.new(fn,logname,loglevel,isCopy)
local mt=getmetatable(fn) if mt and mt.__index==WF then return fn end -- no copy-on-new
if fn==nil then
log.i('copy default windowfilter')
return windowfilter.copy(windowfilter.default,logname,loglevel)
end
local o={filters={},events={},windows={},pending={},
log=logname and logger.new(logname,loglevel or log.level) or log,logname=logname,loglevel=loglevel or log.level}
o.__address=gsub(tostring(o),'table: ','')
setmetatable(o,{__index=WF,__tostring=__tostring,__gc=WF.delete})
o.setLogLevel=o.log.setLogLevel o.getLogLevel=o.log.getLogLevel
if type(fn)=='function' then
o.log.i('new',o,'- custom function')
o.isAppAllowed = function()return true end
o.isWindowAllowed = function(_,w) return fn(w) end
o.customFilter=true
return o
elseif type(fn)=='string' then fn={fn}
end
if type(fn)=='table' then
o.log.i('new',o,'- reject all with exceptions')
return o:setDefaultFilter(false):setFilters(fn)
elseif fn==true then
if isCopy then o.log.i('new',o,'- copy of',isCopy)
else o.log.i('new',o,'- empty') end
return o
elseif fn==false then o.log.i('new',o,'- reject all') return o:setDefaultFilter(false)
else error('fn must be nil, a boolean, a string or table of strings, or a function',2) end
end
--- hs.window.filter.copy(windowfilter[,logname[,loglevel]]) -> hs.window.filter object
--- Constructor
--- Returns a copy of an hs.window.filter object that you can further restrict or expand
---
--- Parameters:
--- * windowfilter - an `hs.window.filter` object to copy
--- * logname - (optional) name of the `hs.logger` instance for the new windowfilter; if omitted, the class logger will be used
--- * loglevel - (optional) log level for the `hs.logger` instance for the new windowfilter
---
--- Returns:
--- * An `hs.window.filter` object
function windowfilter.copy(wf,logname,loglevel)
local mt=getmetatable(wf) if not mt or mt.__index~=WF then error('windowfilter must be an hs.window.filter object',2) end
local self=windowfilter.new(true,logname,loglevel,wf)
local lvl=self.getLogLevel() self.setLogLevel('warning') self:setFilters(wf:getFilters()) self.setLogLevel(lvl)
return self
end
--- hs.window.filter.default
--- Constant
--- The default windowfilter; it filters apps whose windows are transient in nature so that you're unlikely (and often
--- unable) to do anything with them, such as launchers, menulets, preference pane apps, screensavers, etc. It also
--- filters nonstandard and invisible windows.
---
--- Notes:
--- * While you can customize the default windowfilter, it's usually advisable to make your customizations on a local copy via `mywf=hs.window.filter.new()`;
--- the default windowfilter can potentially be used in several Hammerspoon modules and changing it might have unintended consequences.
--- Common customizations:
--- * to exclude fullscreen windows: `nofs_wf=hs.window.filter.new():setOverrideFilter{fullscreen=false}`
--- * to include invisible windows: `inv_wf=windowfilter.new():setDefaultFilter{}`
--- * If you still want to alter the default windowfilter:
--- * you should probably apply your customizations at the top of your `init.lua`, or at any rate before instantiating any other windowfilter; this
--- way copies created via `hs.window.filter.new(nil,...)` will inherit your modifications
--- * to list the known exclusions: `hs.inspect(hs.window.filter.default:getFilters())` from the console
--- * to add an exclusion: `hs.window.filter.default:rejectApp'Cool New Launcher'`
--- * to add an app-specific rule: `hs.window.filter.default:setAppFilter('My IDE',1)`; ignore tooltips/code completion (empty title) in My IDE
--- * to remove an exclusion (e.g. if you want to have access to Spotlight windows): `hs.window.filter.default:allowApp'Spotlight'`;
--- for specialized uses you can make a specific windowfilter with `myfilter=hs.window.filter.new'Spotlight'`
--- hs.window.filter.defaultCurrentSpace
--- Constant
--- A copy of the default windowfilter (see `hs.window.filter.default`) that only allows windows in the current
--- Mission Control Space
---
--- Notes:
--- * This windowfilter will inherit customizations to the default windowfilter if they're performed *before* referencing this
--- hs.window.filter.isGuiApp(appname) -> boolean
--- Function
--- Checks whether an app is a known non-GUI app, as per `hs.window.filter.ignoreAlways`
---
--- Parameters:
--- * appname - name of the app to check as per `hs.application:name()`
---
--- Returns:
--- * `false` if the app is a known non-GUI (or not accessible) app; `true` otherwise
windowfilter.isGuiApp = function(appname)
if not appname then return true
elseif windowfilter.ignoreAlways[appname] then return false
elseif ssub(appname,1,12)=='QTKitServer-' then return false
-- elseif appname=='Hammerspoon' then return false
else return true end
end
-- event watcher (formerly windowwatcher)
local nullEvent='null event'
local events={windowCreated=true, windowDestroyed=true, windowMoved=true,
windowMinimized=true, windowUnminimized=true,
windowHidden=true, windowUnhidden=true,
windowVisible=true, windowNotVisible=true,
windowInCurrentSpace=true,windowNotInCurrentSpace=true,
windowOnScreen=true,windowNotOnScreen=true,
windowFullscreened=true, windowUnfullscreened=true,
--TODO perhaps windowMaximized? (compare win:frame to win:screen:frame) - or include it in windowFullscreened
windowFocused=true, windowUnfocused=true,
windowTitleChanged=true,
windowAllowed=true,windowRejected=true,
hasWindow=true,hasNoWindows=true,
windowsChanged=true,
}
local trackSpacesEvents={
windowInCurrentSpace=true,WindowNotInCurrentSpace=true,
windowOnScreen=true,windowNotOnScreen=true,
}
for k in pairs(events) do windowfilter[k]=k end -- expose events
--- hs.window.filter.windowCreated
--- Constant
--- Event for `hs.window.filter:subscribe()`: a new window was created
--- hs.window.filter.windowDestroyed
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window was destroyed
--- hs.window.filter.windowMoved
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window was moved or resized, including toggling fullscreen/maximize
--- hs.window.filter.windowFullscreened
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window was expanded to fullscreen
--- hs.window.filter.windowUnfullscreened
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window was reverted back from fullscreen
--- hs.window.filter.windowMinimized
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window was minimized
--- hs.window.filter.windowUnminimized
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window was unminimized
--- hs.window.filter.windowUnhidden
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window was unhidden (its app was unhidden, e.g. via `cmd-h`)
--- hs.window.filter.windowHidden
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window was hidden (its app was hidden, e.g. via `cmd-h`)
--- hs.window.filter.windowVisible
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window became "visible" (in *any* Mission Control Space, as per `hs.window:isVisible()`)
--- after having been hidden or minimized, or if it was just created
--- hs.window.filter.windowNotVisible
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window is no longer "visible" (in *any* Mission Control Space, as per `hs.window:isVisible()`)
--- because it was minimized or closed, or its application was hidden (e.g. via `cmd-h`) or closed
--- hs.window.filter.windowInCurrentSpace
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window is now in the current Mission Control Space, due to
--- a Space switch or because it was hidden or minimized (hidden and minimized windows belong to all Spaces)
--- hs.window.filter.windowNotInCurrentSpace
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window that used to be in the current Mission Control Space isn't anymore,
--- due to a Space switch or because it was unhidden or unminimized onto another Space
--- hs.window.filter.windowOnScreen
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window became *actually* visible on screen (i.e. it's "visible" as per `hs.window:isVisible()`
--- *and* in the current Mission Control Space) after having been not visible, or when created
--- hs.window.filter.windowNotOnScreen
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window is no longer *actually* visible on any screen because it was minimized, closed,
--- its application was hidden (e.g. via cmd-h) or closed, or because it's not in the current Mission Control Space anymore
--- hs.window.filter.windowFocused
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window received focus
--- hs.window.filter.windowUnfocused
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window lost focus
--- hs.window.filter.windowTitleChanged
--- Constant
--- Event for `hs.window.filter:subscribe()`: a window's title changed
--- hs.window.filter.windowAllowed
--- Constant
--- Pseudo-event for `hs.window.filter:subscribe()`: a previously rejected window (or a newly created one) is now allowed
---
--- Notes:
--- * this pseudo-event will be emitted *before* the *actual* event(s) (e.g. `windowCreated`) that caused the window to be allowed
--- hs.window.filter.windowRejected
--- Constant
--- Pseudo-event for `hs.window.filter:subscribe()`: a previously allowed window (or a window that's been destroyed) is now rejected
---
--- Notes:
--- * this pseudo-event will be emitted *after* the *actual* event(s) (e.g. `windowDestroyed`) that caused the window to be rejected
--- hs.window.filter.hasWindow
--- Constant
--- Pseudo-event for `hs.window.filter:subscribe()`: the windowfilter now allows one window
---
--- Notes:
--- * callbacks for this event will receive (as the first argument) the window that is now allowed
--- * this pseudo-event won't trigger again until after the windowfilter reverts to rejecting all windows
--- * this pseudo-event will be emitted *after* the *actual* event(s) (e.g. `windowCreated`) that caused a window to be allowed
--- hs.window.filter.hasNoWindows
--- Constant
--- Pseudo-event for `hs.window.filter:subscribe()`: the windowfilter now rejects all windows
---
--- Notes:
--- * callbacks for this event will receive (as the first argument) the last window that was allowed (and is now rejected)
--- * this pseudo-event won't trigger again until after the windowfilter allows at least one window
--- * this pseudo-event will be emitted *after* the *actual* event(s) (e.g. `windowDestroyed`) that caused the window to be rejected
--- hs.window.filter.windowsChanged
--- Constant
--- Pseudo-event for `hs.window.filter:subscribe()`: the list of allowed windows (as per `windowfilter:getWindows()`) has changed
---
--- Notes:
--- * callbacks for this event will receive (as the first argument) either a random window among the currently allowed ones,
--- or nil if the windowfilter is rejecting all windows
--- * similarly, the second argument passed to callbacks (window's app name) will be nil if the windowfilter is rejecting all windows
--- * this pseudo-event will be emitted *after* the *actual* event(s) that caused the list of allowed windows to change
-- Window class
function Window:setFilter(wf,forceremove) -- returns true if filtering status changes
local wasAllowed,isAllowed = wf.windows[self]
if not forceremove then
if wf.customFilter then isAllowed = wf:isWindowAllowed(self.window) or nil
else isAllowed = isWindowAllowed(wf,self) or nil end
end
wf.windows[self] = isAllowed
return wasAllowed ~= isAllowed
end
local function emit(win,wf,event,logged)
local fns=wf.events[event]
if fns then
if not logged then wf.log.df('emitting %s %d (%s)',event,win.id,win.app.name) if wf.log==log then logged=true end end
for fn in pairs(fns) do fn(win.window,win.app.name,event) end
end
return logged
end
local noWindow={app={}} -- emit nil,nil to windowsChanged if no allowed windows
function Window:filterEmitEvent(wf,event,_,logged,notified)
local filteringStatusChanged=self:setFilter(wf,event==windowfilter.windowDestroyed)
local isAllowed=wf.windows[self]
if filteringStatusChanged then
if isAllowed then
emit(self,wf,windowfilter.windowAllowed) -- emit pseudo-event allowed
else
wf.pending[self]=true -- wait for endchain
end
--[[
if wf.notifyfn then -- call notifyfn if present
if not notified then wf.log.d('Notifying windows changed') if wf.log==log then notified=true end end
wf.notifyfn(wf:getWindows(),event)
end
--]]
-- if this is an 'inserted' event, keep around the window until all the events are exhausted
-- if inserted and not isAllowed then wf.pending[self]=true end
end
-- wf.log.f('EVENT %s inserted %s statusChanged %s isallowed %s ispending %s',event,inserted,filteringStatusChanged,wf.windows[self],wf.pending[self])
if isAllowed or wf.pending[self] then
-- window is currently allowed, call subscribers if any
logged=emit(self,wf,event,logged)
-- if not inserted then -- clear the window if this is the last event in the chain
-- if wf.pending[self] then emit(self,wf,windowfilter.windowRejected) end
-- wf.pending[self]=nil
-- end
end
if filteringStatusChanged and isAllowed and not wf.hasWindow then
wf.hasWindow=true
emit(self,wf,windowfilter.hasWindow) -- emit pseudo-event
end
if filteringStatusChanged then
-- emit windowsChanged on a random allowed window
emit(next(wf.windows) or noWindow,wf,windowfilter.windowsChanged,true)
end
return logged,notified
end
function Window:emitEndChain()
for wf in pairs(activeInstances) do
if wf.pending[self] then
emit(self,wf,windowfilter.windowRejected)
if not next(wf.windows) then
emit(self,wf,windowfilter.hasNoWindows) -- emit pseudo-event
wf.hasWindow=nil