1064 lines
44 KiB
Lua
1064 lines
44 KiB
Lua
--- === hs.expose ===
|
|
---
|
|
--- Keyboard-driven expose replacement/enhancement
|
|
---
|
|
--- 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
|
|
---
|
|
--- With this module you can configure a hotkey to show thumbnails for open windows when invoked; each thumbnail will have
|
|
--- an associated keyboard "hint" (usually one or two characters) that you can type to quickly switch focus to that
|
|
--- window; in conjunction with keyboard modifiers, you can additionally minimize (`alt` by default) or close
|
|
--- (`shift` by default) any window without having to focus it first.
|
|
---
|
|
--- When used in combination with a windowfilter you can include or exclude specific apps, window titles, screens,
|
|
--- window roles, etc. Additionally, each expose instance can be customized to include or exclude minimized or hidden windows,
|
|
--- windows residing in other Mission Control Spaces, or only windows for the current application. You can further customize
|
|
--- hint length, colors, fonts and sizes, whether to show window thumbnails and/or titles, and more.
|
|
---
|
|
--- To improve responsiveness, this module will update its thumbnail layout in the background (so to speak), so that it
|
|
--- can show the expose without delay on invocation. Be aware that on particularly heavy Hammerspoon configurations
|
|
--- this could adversely affect overall performance; you can disable this behaviour with
|
|
--- `hs.expose.ui.fitWindowsInBackground=false`
|
|
---
|
|
--- Usage:
|
|
--- ```
|
|
--- -- set up your instance(s)
|
|
--- expose = hs.expose.new(nil,{showThumbnails=false}) -- default windowfilter, no thumbnails
|
|
--- expose_app = hs.expose.new(nil,{onlyActiveApplication=true}) -- show windows for the current application
|
|
--- expose_space = hs.expose.new(nil,{includeOtherSpaces=false}) -- only windows in the current Mission Control Space
|
|
--- expose_browsers = hs.expose.new{'Safari','Google Chrome'} -- specialized expose using a custom windowfilter
|
|
--- -- for your dozens of browser windows :)
|
|
---
|
|
--- -- then bind to a hotkey
|
|
--- hs.hotkey.bind('ctrl-cmd','e','Expose',function()expose:toggleShow()end)
|
|
--- hs.hotkey.bind('ctrl-cmd-shift','e','App Expose',function()expose_app:toggleShow()end)
|
|
--- ```
|
|
|
|
--TODO /// hs.drawing:setClickCallback(fn) -> drawingObject
|
|
--TODO showExtraKeys
|
|
|
|
--local print=function()end
|
|
|
|
local min,max,ceil,abs,fmod,floor,random=math.min,math.max,math.ceil,math.abs,math.fmod,math.floor,math.random
|
|
local next,type,ipairs,pairs,sformat,supper,ssub,tostring=next,type,ipairs,pairs,string.format,string.upper,string.sub,tostring
|
|
local tinsert,tremove,tsort,setmetatable,rawset=table.insert,table.remove,table.sort,setmetatable,rawset
|
|
|
|
local geom=require'hs.geometry'
|
|
local drawing,image=require'hs.drawing',require'hs.image'
|
|
local window,screen=require'hs.window',require'hs.screen'
|
|
local windowfilter=require'hs.window.filter'
|
|
local application,spaces=require'hs.application',require'hs.spaces'
|
|
local eventtap=require'hs.eventtap'
|
|
local newmodal=require'hs.hotkey'.modal.new
|
|
local asciiOnly=require'hs.utf8'.asciiOnly
|
|
local function stripUnicode(s)
|
|
return asciiOnly(s):gsub('\\x[0-9A-F][0-9A-F]','')
|
|
end
|
|
local timer,logger=require'hs.timer',require'hs.logger'
|
|
local log=logger.new('expose')
|
|
|
|
local expose={setLogLevel=log.setLogLevel,getLogLevel=log.getLogLevel} --module
|
|
local activeInstances={} -- these are updated in the background
|
|
local modals={} -- modal hotkeys for selecting a hint; global state
|
|
local activeInstance,fnreactivate -- function to reactivate the current instance (only 1 possible) after a space switch
|
|
local modes,tap={} -- modes (minimize, close) for the current instance, and eventtap (glboals)
|
|
local spacesWatcher,screenWatcher,screensChangedTimer,bgFitTimer -- global watchers
|
|
local BG_FIT_INTERVAL=3
|
|
local BEHAVIOR=17
|
|
|
|
local function tlen(t)
|
|
if not t then return 0 end
|
|
local l=0 for _ in pairs(t) do l=l+1 end return l
|
|
end
|
|
|
|
local function isAreaEmpty(rect,w,windows,screenFrame)
|
|
if not rect:inside(screenFrame) then return end
|
|
for _,w2 in pairs(windows) do if w2~=w and w2.frame:intersect(rect).area>0 then return end end
|
|
return true
|
|
end
|
|
local function sortedWindows(t,comp)
|
|
local r={} for _,w in pairs(t) do r[#r+1]=w end
|
|
tsort(r,comp) return r
|
|
end
|
|
|
|
local function fitWindows(self,screen,maxIterations)
|
|
if not screen.dirty then return end
|
|
local screenFrame=screen.frame
|
|
local windows=screen.windows
|
|
local nwindows=tlen(windows)
|
|
if nwindows==0 then screen.dirty=nil return end
|
|
local haveThumbs,isStrip=screen.thumbnails,screen.isStrip
|
|
local optimalRatio=min(1,screenFrame.area/screen.totalOriginalArea)
|
|
local accRatio=0
|
|
|
|
local minWidth,minHeight=self.ui.minWidth,self.ui.minHeight
|
|
local longSide=max(screenFrame.w,screenFrame.h)
|
|
local maxDisplace=longSide/20
|
|
local VEC00=geom.new(0,0)
|
|
local edge=(isStrip and not haveThumbs) and screen.edge or VEC00
|
|
|
|
if not haveThumbs then -- "fast" mode
|
|
for _,w in pairs(windows) do
|
|
if w.dirty then w.frame:setw(minWidth):seth(minHeight):setcenter(w.originalFrame.center):fit(screenFrame) w.ratio=1 end
|
|
w.weight=1/nwindows
|
|
end
|
|
accRatio=1
|
|
maxDisplace=max(minWidth,minHeight)*0.5
|
|
else
|
|
local isVertical=screen.pos=='left' or screen.pos=='right'
|
|
local s=(longSide*0.7/nwindows)/(isVertical and minHeight or minWidth)
|
|
if isStrip and s<1 then
|
|
minWidth,minHeight=minWidth*s,minHeight*s
|
|
local t=sortedWindows(windows,isVertical and
|
|
function(w1,w2) return w1.frame.y<w2.frame.y end or
|
|
function(w1,w2) return w1.frame.x<w2.frame.x end)
|
|
local inc=longSide/nwindows
|
|
for i,w in ipairs(t) do
|
|
-- if w.dirty then w.frame=geom.new(inc*(i-1)+screenFrame.x,inc*(i-1)+screenFrame.y,minWidth,minHeight) end
|
|
if w.dirty then w.frame:setx(inc*(i-1)+screenFrame.x):sety(inc*(i-1)+screenFrame.y):fit(screenFrame) end
|
|
w.ratio=w.frame.area/w.originalFrame.area
|
|
w.weight=w.originalFrame.area/screen.totalOriginalArea
|
|
accRatio=accRatio+w.ratio*w.weight
|
|
end
|
|
maxDisplace=max(minWidth,minHeight)*0.5
|
|
else
|
|
for _,w in pairs(windows) do
|
|
if w.dirty then w.frame=geom.copy(w.originalFrame):scale(min(1,optimalRatio*2)) w.ratio=min(1,optimalRatio*2)
|
|
else w.ratio=w.frame.area/w.originalFrame.area end
|
|
w.weight=w.originalFrame.area/screen.totalOriginalArea
|
|
accRatio=accRatio+w.ratio*w.weight
|
|
end
|
|
end
|
|
end
|
|
local avgRatio=accRatio
|
|
if nwindows==1 then maxIterations=1 end
|
|
local didwork,iterations = true,0
|
|
local TESTFRAMES={{S=1,s=1,weight=3},{S=1.08,s=1.02,weight=1},{S=1.4,s=1.1,weight=0.3},{S=2.5,s=1.5,weight=0.02}}
|
|
local MAXTEST=haveThumbs and (isStrip and 3 or #TESTFRAMES) or 3
|
|
while didwork and iterations<maxIterations do
|
|
didwork,accRatio,iterations=false,0,iterations+1
|
|
local totalOverlaps=0
|
|
for _,w in pairs(windows) do
|
|
local wframe,wratio=w.frame,w.ratio
|
|
accRatio=accRatio+wratio*w.weight
|
|
for i=MAXTEST,1,-1 do local test=TESTFRAMES[i]
|
|
local ovs,tarea,weight={},0,test.weight
|
|
for _,testframe in ipairs{geom.copy(wframe):scale(test.S,test.s),geom.copy(wframe):scale(test.s,test.S)} do
|
|
for _,w2 in pairs(windows) do if w~=w2 then
|
|
local intersection=testframe:intersect(w2.frame)
|
|
local area=intersection.area
|
|
if area>0 then
|
|
tarea=tarea+area
|
|
ovs[#ovs+1]=intersection
|
|
end
|
|
end end
|
|
end
|
|
if tarea>0 then
|
|
local ac=geom.copy(VEC00)
|
|
for _,ov in ipairs(ovs) do ac=ac+ov.center*(ov.area/tarea) end
|
|
ac=(wframe.center-ac) * (tarea/wframe.area*weight*(isStrip and 3 or 3))
|
|
if ac.length>maxDisplace then ac.length=maxDisplace
|
|
-- else
|
|
-- ac:move(random(-10,10)/20,random(-10,10)/20)
|
|
-- end
|
|
elseif ac:floor()==VEC00 then ac:move(random(-10,10)/20,random(-10,10)/20) end
|
|
wframe:move(ac):fit(screenFrame)
|
|
-- if i<=2 then didwork=true end
|
|
if i==1 then
|
|
totalOverlaps=totalOverlaps+1
|
|
if haveThumbs then
|
|
if wratio*1.25>avgRatio then --shrink
|
|
wframe:scale(0.965)
|
|
didwork=true
|
|
else
|
|
for _,w2 in pairs(windows) do w2.frame:scale(0.98) w2.ratio=w2.ratio*0.98 end
|
|
accRatio=accRatio*0.98
|
|
end
|
|
end
|
|
end
|
|
elseif i==2 then
|
|
if haveThumbs and wratio<avgRatio*1.25 and wratio<optimalRatio then -- grow
|
|
wframe:scale(1.04)
|
|
if not didwork and wframe.w<screenFrame.w and wframe.h<screenFrame.h then didwork=true end
|
|
end
|
|
break
|
|
end
|
|
end
|
|
wframe:move(edge):fit(screenFrame)
|
|
w.frame=wframe w.ratio=wframe.area/w.originalFrame.area
|
|
end
|
|
didwork=didwork or totalOverlaps>0
|
|
local halting=iterations==maxIterations
|
|
if not didwork or halting then
|
|
local totalArea,totalRatio=0,0
|
|
for _,win in pairs(windows) do
|
|
totalArea=totalArea+win.frame.area
|
|
totalRatio=totalRatio+win.ratio
|
|
win.frames[screen]=geom.copy(win.frame)
|
|
win.dirty=nil
|
|
end
|
|
self.log.vf('%s: %s (%d iter), coverage %.2f%%, ratio %.2f%%/%.2f%%, %d overlaps',screen.name,
|
|
didwork and 'halted' or 'optimal',iterations,totalArea/(screenFrame.area)*100,totalRatio/nwindows*100,optimalRatio*100,totalOverlaps)
|
|
if not didwork then screen.dirty=nil end
|
|
else avgRatio=accRatio end
|
|
end
|
|
end
|
|
|
|
local uiGlobal = {
|
|
textColor={0.9,0.9,0.9,1},
|
|
fontName='Lucida Grande',
|
|
textSize=40,
|
|
|
|
highlightColor={0.6,0.3,0.0,1},
|
|
|
|
backgroundColor={0.03,0.03,0.03,1},
|
|
closeModeModifier = 'shift',
|
|
closeModeBackgroundColor={0.7,0.1,0.1,1},
|
|
minimizeModeModifier = 'alt',
|
|
minimizeModeBackgroundColor={0.1,0.2,0.3,1},
|
|
onlyActiveApplication=false,
|
|
includeNonVisible=true,
|
|
nonVisibleStripPosition='bottom',
|
|
nonVisibleStripBackgroundColor={0.03,0.1,0.15,1},
|
|
nonVisibleStripWidth=0.1,
|
|
includeOtherSpaces=true,
|
|
otherSpacesStripBackgroundColor={0.1,0.1,0.1,1},
|
|
otherSpacesStripWidth=0.2,
|
|
otherSpacesStripPosition='top',
|
|
|
|
showTitles=true,
|
|
showThumbnails=true,
|
|
thumbnailAlpha=0,
|
|
highlightThumbnailAlpha=1,
|
|
highlightThumbnailStrokeWidth=8,
|
|
|
|
maxHintLetters = 2,
|
|
|
|
fitWindowsMaxIterations=30,
|
|
fitWindowsInBackground=false,
|
|
fitWindowsInBackgroundMaxIterations=3,
|
|
fitWindowsInBackgroundMaxRepeats=10,
|
|
|
|
showExtraKeys=true,
|
|
}
|
|
|
|
local function getColor(t) if type(t)~='table' or t.red or not t[1] then return t else return {red=t[1] or 0,green=t[2] or 0,blue=t[3] or 0,alpha=t[4] or 1} end end
|
|
|
|
--- hs.expose.ui
|
|
--- Variable
|
|
--- Allows customization of the expose behaviour and user interface
|
|
---
|
|
--- This table contains variables that you can change to customize the behaviour of the expose and the look of the UI.
|
|
--- To have multiple expose instances with different behaviour/looks, use the `uiPrefs` parameter for the constructor;
|
|
--- the passed keys and values will override those in this table for that particular instance.
|
|
---
|
|
--- The default values are shown in the right hand side of the assignements below.
|
|
---
|
|
--- To represent color values, you can use:
|
|
--- * a table {red=redN, green=greenN, blue=blueN, alpha=alphaN}
|
|
--- * a table {redN,greenN,blueN[,alphaN]} - if omitted alphaN defaults to 1.0
|
|
--- where redN, greenN etc. are the desired value for the color component between 0.0 and 1.0
|
|
---
|
|
--- * `hs.expose.ui.textColor = {0.9,0.9,0.9}`
|
|
--- * `hs.expose.ui.fontName = 'Lucida Grande'`
|
|
--- * `hs.expose.ui.textSize = 40` - in screen points
|
|
--- * `hs.expose.ui.highlightColor = {0.8,0.5,0,0.1}` - highlight color for candidate windows
|
|
--- * `hs.expose.ui.backgroundColor = {0.30,0.03,0.03,1}`
|
|
--- * `hs.expose.ui.closeModeModifier = 'shift'` - "close mode" engaged while pressed (or 'cmd','ctrl','alt')
|
|
--- * `hs.expose.ui.closeModeBackgroundColor = {0.7,0.1,0.1,1}` - background color while "close mode" is engaged
|
|
--- * `hs.expose.ui.minimizeModeModifier = 'alt'` - "minimize mode" engaged while pressed
|
|
--- * `hs.expose.ui.minimizeModeBackgroundColor = {0.1,0.2,0.3,1}` - background color while "minimize mode" is engaged
|
|
--- * `hs.expose.ui.onlyActiveApplication = false` -- only show windows of the active application
|
|
--- * `hs.expose.ui.includeNonVisible = true` - include minimized and hidden windows
|
|
--- * `hs.expose.ui.nonVisibleStripBackgroundColor = {0.03,0.1,0.15,1}` - contains hints for non-visible windows
|
|
--- * `hs.expose.ui.nonVisibleStripPosition = 'bottom'` - set it to your Dock position ('bottom', 'left' or 'right')
|
|
--- * `hs.expose.ui.nonVisibleStripWidth = 0.1` - 0..0.5, width of the strip relative to the screen
|
|
--- * `hs.expose.ui.includeOtherSpaces = true` - include windows in other Mission Control Spaces
|
|
--- * `hs.expose.ui.otherSpacesStripBackgroundColor = {0.1,0.1,0.1,1}`
|
|
--- * `hs.expose.ui.otherSpacesStripPosition = 'top'`
|
|
--- * `hs.expose.ui.otherSpacesStripWidth = 0.2`
|
|
--- * `hs.expose.ui.showTitles = true` - show window titles
|
|
--- * `hs.expose.ui.showThumbnails = true` - show window thumbnails
|
|
--- * `hs.expose.ui.thumbnailAlpha = 0` - 0..1, opacity for thumbnails
|
|
--- * `hs.expose.ui.highlightThumbnailAlpha = 1` - 0..1, opacity for thumbnails of candidate windows
|
|
--- * `hs.expose.ui.highlightThumbnailStrokeWidth = 8` - thumbnail frame thickness for candidate windows
|
|
--- * `hs.expose.ui.maxHintLetters = 2` - if necessary, hints longer than this will be disambiguated with digits
|
|
--- * `hs.expose.ui.fitWindowsMaxIterations = 30` -- lower is faster, but higher chance of overlapping thumbnails
|
|
--- * `hs.expose.ui.fitWindowsInBackground = false` -- improves responsivenss, but can affect the rest of the config
|
|
|
|
-- TODO * `hs.expose.ui.fitWindowsMaxIterations = 3`
|
|
-- TODO * `hs.expose.ui.showExtraKeys = true` -- show non-hint keybindings at the top of the screen
|
|
|
|
expose.ui=setmetatable({},{
|
|
__newindex=function(t,k,v) uiGlobal[k]=getColor(v) end,
|
|
__index=function(t,k)return getColor(uiGlobal[k])end,
|
|
})
|
|
|
|
|
|
|
|
local function getHints(self,windows)
|
|
local function hasSubHints(t)
|
|
for k,v in pairs(t) do if type(k)=='string' and #k==1 then return true end end
|
|
end
|
|
local hints={apps={}}
|
|
local reservedHint=1
|
|
for _,screen in pairs(self.screens) do
|
|
for id,w in pairs(screen.windows) do
|
|
if not windows or windows[id] then
|
|
local appname=stripUnicode(w.appname or '')
|
|
while #appname<self.ui.maxHintLetters do
|
|
appname=appname..tostring(reservedHint) reservedHint=reservedHint+1
|
|
end
|
|
w.appname=appname
|
|
hints[#hints+1]=w
|
|
hints.apps[appname]=(hints.apps[appname] or 0)+1
|
|
w.hint=''
|
|
end
|
|
end
|
|
end
|
|
local function normalize(t,n) --change in place
|
|
local _
|
|
while #t>0 and tlen(t.apps)>0 do
|
|
if n>self.ui.maxHintLetters or (tlen(t.apps)==1 and n>1 and not hasSubHints(t)) then
|
|
-- last app remaining for this hint; give it digits
|
|
local app=next(t.apps)
|
|
t.apps={}
|
|
if #t>1 then
|
|
--fix so that accumulation is possible
|
|
local total=#t
|
|
for i,w in ipairs(t) do
|
|
t[i]=nil
|
|
local c=tostring(total<10 and i-(t.m1 and 1 or 0) or floor(i/10))
|
|
t[c]=t[c] or {}
|
|
tinsert(t[c],w)
|
|
if #t[c]>1 then t[c].apps={app=#t[c]} t[c].m1=c~='0' end
|
|
w.hint=w.hint..c
|
|
end
|
|
end
|
|
else
|
|
-- find the app with least #windows and add a hint to it
|
|
local minfound,minapp=9999
|
|
for appname,nwindows in pairs(t.apps) do
|
|
if nwindows<minfound then minfound=nwindows minapp=appname end
|
|
end
|
|
t.apps[minapp]=nil
|
|
local c=supper(ssub(minapp,n,n))
|
|
--TODO what if not long enough
|
|
t[c]=t[c] or {apps={}}
|
|
t[c].apps[minapp]=minfound
|
|
local i=1
|
|
while i<=#t do
|
|
if t[i].appname==minapp then
|
|
local w=tremove(t,i)
|
|
tinsert(t[c],w)
|
|
w.hint=w.hint..c
|
|
else i=i+1 end
|
|
end
|
|
end
|
|
end
|
|
for c,subt in pairs(t) do
|
|
if type(c)=='string' and #c==1 then
|
|
normalize(subt,n+1)
|
|
end
|
|
end
|
|
end
|
|
|
|
normalize(hints,1)
|
|
return hints
|
|
end
|
|
|
|
local function updateHighlights(ui,hints,subtree,entering,show)
|
|
for c,t in pairs(hints) do
|
|
if t==subtree then
|
|
updateHighlights(ui,t,nil,entering,true)
|
|
elseif type(c)=='string' and #c==1 then
|
|
local w=t[1]
|
|
if w then
|
|
if ui.showThumbnails then
|
|
if show then w.thumb:setAlpha(ui.highlightThumbnailAlpha) w.highlight:show()
|
|
else w.thumb:setAlpha(ui.thumbnailAlpha) w.highlight:hide() end
|
|
end
|
|
if ui.showTitles then
|
|
if show then w.titlerect:show() w.titletext:show()
|
|
else w.titletext:hide() w.titlerect:hide() end
|
|
end
|
|
if show then
|
|
w.hintrect:show()
|
|
w.curhint=ssub(' ',1,#modals+(entering and 0 or -1))..ssub(w.hint,#modals+(entering and 1 or 0))
|
|
w.hinttext:setText(w.curhint):show()
|
|
w.icon:show()
|
|
else
|
|
w.hinttext:hide()
|
|
w.hintrect:hide()
|
|
w.icon:hide()
|
|
end
|
|
w.visible=show
|
|
else updateHighlights(ui,t,subtree,entering,show) end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function setMode(self,k,mode)
|
|
if modes[k]==mode then return end
|
|
modes[k]=mode
|
|
for s,screen in pairs(self.screens) do
|
|
if modes[k] then
|
|
screen.bg:setFillColor(k=='close' and self.ui.closeModeBackgroundColor or self.ui.minimizeModeBackgroundColor)
|
|
elseif s=='inv' then
|
|
screen.bg:setFillColor(self.ui.nonVisibleStripBackgroundColor)
|
|
elseif type(s)=='string' then
|
|
screen.bg:setFillColor(self.ui.otherSpacesStripBackgroundColor)
|
|
else
|
|
screen.bg:setFillColor(self.ui.backgroundColor)
|
|
end
|
|
end
|
|
end
|
|
|
|
local enter--,setThumb
|
|
|
|
local function exit(self)
|
|
self.log.vf('exit modal for hint #%d',#modals)
|
|
tremove(modals).modal:exit()
|
|
if #modals>0 then updateHighlights(self.ui,modals[#modals].hints,nil,false,true) return enter(self) end
|
|
-- exit all
|
|
local showThumbs,showTitles=self.ui.showThumbnails,self.ui.showTitles
|
|
for _,s in pairs(self.screens) do
|
|
for _,w in pairs(s.windows) do
|
|
if showThumbs then w.thumb:hide() w.highlight:hide() end
|
|
if showTitles then w.titletext:hide() w.titlerect:hide() end
|
|
if w.icon then w.icon:hide() w.hinttext:hide() w.hintrect:hide() end
|
|
-- if w.rect then w.rect:delete() end
|
|
if w.textratio then w.textratio:hide() end
|
|
end
|
|
s.bg:hide()
|
|
end
|
|
tap:stop()
|
|
fnreactivate,activeInstance=nil,nil
|
|
-- return exitAll(self)
|
|
-- end
|
|
-- return enter(self)
|
|
end
|
|
local function exitAll(self,toFocus)
|
|
self.log.d('exiting')
|
|
while #modals>0 do exit(self) end
|
|
if toFocus then
|
|
self.log.i('focusing',toFocus)
|
|
-- if toFocus:application():bundleID()~='com.apple.finder' then
|
|
-- toFocus:focus()
|
|
-- else
|
|
timer.doAfter(0.25,function()toFocus:focus()end) -- el cap bugs out (desktop "floats" on top) if done directly
|
|
-- end
|
|
end
|
|
end
|
|
|
|
enter=function(self,hints)
|
|
if not hints then modals[#modals].modal:enter()
|
|
elseif hints[1] then
|
|
--got a hint
|
|
updateHighlights(self.ui,modals[#modals].hints,nil,false,true)
|
|
local h,w=hints[1],hints[1].window
|
|
local app,appname=w:application(),h.appname
|
|
if modes.close then
|
|
self.log.f('closing window (%s)',appname)
|
|
w:close()
|
|
hints[1]=nil
|
|
-- close app
|
|
if app then
|
|
if #app:allWindows()==0 then
|
|
self.log.f('quitting application %s',appname)
|
|
app:kill()
|
|
end
|
|
end
|
|
-- updateHighlights(self.ui,modals[#modals].hints,nil,false,true)
|
|
return enter(self)
|
|
elseif modes.min then
|
|
self.log.f('toggling window minimized/hidden (%s)',appname)
|
|
if w:isMinimized() then w:unminimize()
|
|
elseif app:isHidden() then app:unhide()
|
|
else w:minimize() end
|
|
-- updateHighlights(self.ui,modals[#modals].hints,nil,false,true)
|
|
return enter(self)
|
|
else
|
|
self.log.f('focusing window (%s)',appname)
|
|
if w:isMinimized() then w:unminimize() end
|
|
-- w:focus()
|
|
return exitAll(self,w)
|
|
end
|
|
else
|
|
if modals[#modals] then self.log.vf('exit modal %d',#modals) modals[#modals].modal:exit() end
|
|
local modal=newmodal()
|
|
modals[#modals+1]={modal=modal,hints=hints}
|
|
modal:bind({},'escape',function()return exitAll(self)end)
|
|
modal:bind({},'delete',function()return exit(self)end)
|
|
for c,t in pairs(hints) do
|
|
if type(c)=='string' and #c==1 then
|
|
modal:bind({},c,function()updateHighlights(self.ui,hints,t,true) enter(self,t) end)
|
|
modal:bind({self.ui.closeModeModifier},c,function()updateHighlights(self.ui,hints,t,true) enter(self,t) end)
|
|
modal:bind({self.ui.minimizeModeModifier},c,function()updateHighlights(self.ui,hints,t,true) enter(self,t) end)
|
|
end
|
|
end
|
|
self.log.vf('enter modal for hint #%d',#modals)
|
|
modal:enter()
|
|
end
|
|
end
|
|
|
|
local function spaceChanged()
|
|
if not activeInstance then return end
|
|
local temp=fnreactivate
|
|
exitAll(activeInstance)
|
|
return temp()
|
|
end
|
|
|
|
local function setThumbnail(w,screenFrame,thumbnails,titles,ui,bg)
|
|
local wframe=w.frame
|
|
if thumbnails then
|
|
w.thumb:setFrame(wframe):orderAbove(bg)
|
|
w.highlight:setFrame(wframe):orderAbove(w.thumb)
|
|
end
|
|
-- local hwidth=#w.hint*ui.hintLetterWidth
|
|
local hintWidth=drawing.getTextDrawingSize(w.hint or '',ui.hintTextStyle).w
|
|
local hintHeight=ui.hintHeight
|
|
local padding=hintHeight*0.1
|
|
local br=geom.copy(wframe):seth(hintHeight):setw(hintWidth+hintHeight+padding*4):setcenter(wframe.center):fit(screenFrame)
|
|
local tr=geom.copy(br):setw(hintWidth+padding*2):move(hintHeight+padding*2,0)
|
|
local ir=geom.copy(br):setw(hintHeight):move(padding,0)
|
|
w.hintrect:setFrame(br):orderAbove(w.highlight or bg)
|
|
w.hinttext:setFrame(tr):orderAbove(w.hintrect):setText(w.curhint or w.hint or ' ')
|
|
w.icon:setFrame(ir):orderAbove(w.hintrect)
|
|
|
|
if titles then
|
|
local titleWidth=min(wframe.w,w.titleWidth)
|
|
local tr=geom.copy(wframe):seth(ui.titleHeight):setw(titleWidth+8)
|
|
:setcenter(wframe.center):move(0,ui.hintHeight):fit(screenFrame)
|
|
w.titlerect:setFrame(tr):orderAbove(w.highlight or bg)
|
|
w.titletext:setFrame(tr):orderAbove(w.titlerect)
|
|
end
|
|
end
|
|
|
|
|
|
local UNAVAILABLE=image.imageFromName'NSStopProgressTemplate'
|
|
|
|
local function showExpose(self,windows,animate,alt_algo)
|
|
-- animate is waaay to slow: don't bother
|
|
-- alt_algo sometimes performs better in terms of coverage, but (in the last half-broken implementation) always reaches maxIterations
|
|
-- alt_algo TL;DR: much slower, don't bother
|
|
if not self.running then self.log.i('instance not running, cannot show expose') return end
|
|
self.log.d('activated')
|
|
local hints=getHints(self,windows)
|
|
local ui=self.ui
|
|
for sid,s in pairs(self.screens) do
|
|
if animate and ui.showThumbnails then
|
|
s.bg:show():orderBelow()
|
|
for _,w in pairs(s.windows) do
|
|
w.thumb = drawing.image(w.originalFrame,window.snapshotForID(w.id)):show() --FIXME
|
|
end end
|
|
fitWindows(self,s,ui.fitWindowsMaxIterations,animate and 0 or nil,alt_algo)
|
|
local bg,screenFrame,thumbnails,titles=s.bg:show(),s.frame,s.thumbnails,ui.showTitles
|
|
for id,w in pairs(s.windows) do
|
|
if not windows or windows[id] then
|
|
setThumbnail(w,screenFrame,thumbnails,titles,ui,bg)
|
|
-- if showThumbs then w.thumb:show() w.highlight:show() end
|
|
-- if showTitles then w.titlerect:show() w.titletext:show() end
|
|
-- w.hintrect:show() w.hinttext:show() w.icon:show()
|
|
if w.textratio then w.textratio:show() end
|
|
end
|
|
end
|
|
end
|
|
tap=eventtap.new({eventtap.event.types.flagsChanged},function(e)
|
|
local function hasOnly(t,mod)
|
|
local n=next(t)
|
|
if n~=mod then return end
|
|
if not next(t,n) then return true end
|
|
end
|
|
setMode(self,'close',hasOnly(e:getFlags(),self.ui.closeModeModifier))
|
|
setMode(self,'min',hasOnly(e:getFlags(),self.ui.minimizeModeModifier))
|
|
end)
|
|
tap:start()
|
|
enter(self,hints)
|
|
end
|
|
|
|
--- hs.expose:toggleShow([activeApplication])
|
|
--- Method
|
|
--- Toggles the expose - see `hs.expose:show()` and `hs.expose:hide()`
|
|
---
|
|
--- Parameters:
|
|
--- * activeApplication - (optional) if true, only show windows of the active application (within the scope of the instance windowfilter); otherwise show all windows allowed by the instance windowfilter
|
|
---
|
|
--- Returns:
|
|
--- * None
|
|
---
|
|
--- Notes:
|
|
--- * passing `true` for `activeApplication` will simply hide hints/thumbnails for applications other than the active one, without recalculating the hints layout; conversely, setting `onlyActiveApplication=true` for an expose instance's `ui` will calculate an optimal layout for the current active application's windows
|
|
--- * Completing a hint will exit the expose and focus the selected window.
|
|
--- * Pressing esc will exit the expose and with no action taken.
|
|
--- * If shift is being held when a hint is completed (the background will be red), the selected window will be closed. If it's the last window of an application, the application will be closed.
|
|
--- * If alt is being held when a hint is completed (the background will be blue), the selected window will be minimized (if visible) or unminimized/unhidden (if minimized or hidden).
|
|
---
|
|
--- Returns:
|
|
--- * None
|
|
function expose:toggleShow(...)
|
|
if activeInstance then return self:hide() else return self:show(...) end
|
|
end
|
|
--- hs.expose:hide()
|
|
--- Method
|
|
--- Hides the expose, if visible, and exits the modal mode
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * None
|
|
---
|
|
--- Notes:
|
|
--- * Call this function if you need to make sure the modal is exited without waiting for the user to press `esc`
|
|
function expose:hide()
|
|
if activeInstance then return exitAll(activeInstance) end
|
|
end
|
|
--- hs.expose:show([activeApplication])
|
|
--- Method
|
|
--- Shows an expose-like screen with modal keyboard hints for switching to, closing or minimizing/unminimizing windows.
|
|
---
|
|
--- Parameters:
|
|
--- * activeApplication - (optional) if true, only show windows of the active application (within the
|
|
--- scope of the instance windowfilter); otherwise show all windows allowed by the instance windowfilter
|
|
---
|
|
--- Returns:
|
|
--- * None
|
|
---
|
|
--- Notes:
|
|
--- * passing `true` for `activeApplication` will simply hide hints/thumbnails for applications other
|
|
--- than the active one, without recalculating the hints layout; conversely, setting `onlyActiveApplication=true`
|
|
--- for an expose instance's `ui` will calculate an optimal layout for the current active application's windows
|
|
--- * Completing a hint will exit the expose and focus the selected window.
|
|
--- * Pressing esc will exit the expose and with no action taken.
|
|
--- * If shift is being held when a hint is completed (the background will be red), the selected
|
|
--- window will be closed. If it's the last window of an application, the application will be closed.
|
|
--- * If alt is being held when a hint is completed (the background will be blue), the selected
|
|
--- window will be minimized (if visible) or unminimized/unhidden (if minimized or hidden).
|
|
|
|
local function getApplicationWindows()
|
|
local a=application.frontmostApplication()
|
|
if not a then log.w('cannot get active application') return end
|
|
local r={}
|
|
for _,w in ipairs(a:allWindows()) do r[w:id()]=w end
|
|
return r
|
|
end
|
|
|
|
function expose:show(currentApp,...)
|
|
if activeInstance then return end
|
|
activeInstance=self
|
|
fnreactivate=function()return self:show(currentApp)end
|
|
return showExpose(self,currentApp and getApplicationWindows() or nil,...)
|
|
end
|
|
|
|
local bgRepeats=0
|
|
local function bgFitWindows()
|
|
local rep
|
|
for self in pairs(activeInstances) do
|
|
local DEBUG,DEBUG_TIME=self.ui.DEBUG
|
|
if DEBUG then DEBUG_TIME=timer.secondsSinceEpoch() end
|
|
local iters=self.ui.fitWindowsInBackgroundMaxIterations --3--math.random(9)
|
|
if self.dirty then
|
|
for _,screen in pairs(self.screens) do
|
|
if screen.dirty then fitWindows(self,screen,iters) rep=rep or screen.dirty end
|
|
if activeInstance==self or DEBUG then
|
|
for _,w in pairs(screen.windows) do
|
|
if w.visible then setThumbnail(w,screen.frame,screen.thumbnails,self.ui.showTitles,self.ui,screen.bg) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if DEBUG then print(math.floor((timer.secondsSinceEpoch()-DEBUG_TIME)/iters*1000)..'ms per iteration - '..iters..' total') end
|
|
end
|
|
bgRepeats=bgRepeats-1
|
|
if rep and bgRepeats>0 then bgFitTimer:start() end
|
|
end
|
|
|
|
function expose.STOP()
|
|
bgFitTimer:stop()
|
|
for i in pairs(activeInstances) do
|
|
for _,s in pairs(i.screens) do
|
|
for _,w in pairs(s.windows) do
|
|
if w.thumb then w.thumb:hide() w.highlight:hide() end
|
|
if w.titletext then w.titletext:hide() w.titlerect:hide() end
|
|
if w.icon then w.icon:hide() w.hinttext:hide() w.hintrect:hide() end
|
|
-- if w.rect then w.rect:delete() end
|
|
if w.textratio then w.textratio:hide() end
|
|
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local function startBgFitWindows(ui)
|
|
if activeInstance and not ui.fitWindowsInBackground then bgRepeats=2 return bgFitTimer:start(0.05) end
|
|
bgRepeats=ui.fitWindowsInBackgroundMaxRepeats
|
|
if bgRepeats>0 and ui.fitWindowsInBackground then
|
|
bgFitTimer:start()
|
|
end
|
|
end
|
|
|
|
local function windowRejected(self,win,appname,screen)
|
|
local id=win:id()
|
|
local w=self.windows[id]
|
|
if not w then return end
|
|
if screen.windows[id] then
|
|
self.log.vf('window %s (%d) <- %s',appname,id,screen.name)
|
|
screen.totalOriginalArea=screen.totalOriginalArea-w.originalFrame.area
|
|
screen.windows[id]=nil
|
|
screen.dirty=true
|
|
return startBgFitWindows(self.ui)
|
|
end
|
|
end
|
|
|
|
local function windowDestroyed(self,win,appname,screen)
|
|
local id=win:id()
|
|
local w=self.windows[id]
|
|
if not w then return end
|
|
windowRejected(self,win,appname,screen)
|
|
if w.thumb then w.thumb:delete() w.highlight:delete() end
|
|
if w.titletext then w.titletext:delete() w.titlerect:delete() end
|
|
w.hintrect:delete() w.hinttext:delete() w.icon:delete()
|
|
self.windows[id]=nil
|
|
self.dirty=true
|
|
end
|
|
|
|
local function getTitle(self,w)
|
|
local title=w.window:title() or ' '
|
|
w.titleWidth=drawing.getTextDrawingSize(title,self.ui.titleTextStyle).w
|
|
w.titletext:setText(title)
|
|
end
|
|
local function windowAllowed(self,win,appname,screen)
|
|
-- print('addwindow '..appname..' to '..screen.name)
|
|
local id=win:id()
|
|
local w=self.windows[id]
|
|
if w then
|
|
local prevScreen=w.screen
|
|
w.screen=screen -- set new screen
|
|
windowRejected(self,win,appname,prevScreen) --remove from previous screen
|
|
local cached=w.frames[screen]
|
|
self.log.vf('window %s (%d) -> %s%s',appname,id,screen.name,cached and ' [CACHED]' or '')
|
|
w.frame=geom.copy(cached or w.originalFrame)
|
|
w.dirty=not cached
|
|
screen.windows[id]=w
|
|
screen.totalOriginalArea=screen.totalOriginalArea+w.originalFrame.area
|
|
screen.dirty=screen.dirty or not cached or true
|
|
return startBgFitWindows(self.ui)
|
|
end
|
|
|
|
self.log.df('window %s (%d) created',appname,id)
|
|
local ui=self.ui
|
|
local f=win:frame()
|
|
-- if not screen.thumbnails then f.aspect=1 local side=ui.minWidth f.area=side*side end
|
|
local w={window=win,appname=appname,originalFrame=geom.copy(f),frame=f,ratio=1,frames={},id=id,screen=screen}
|
|
if ui.showThumbnails then
|
|
w.thumb=drawing.image(f,window.snapshotForID(id) or UNAVAILABLE):setAlpha(ui.highlightThumbnailAlpha)
|
|
:setBehavior(BEHAVIOR)
|
|
w.highlight=drawing.rectangle(f):setFill(false)
|
|
:setStrokeWidth(ui.highlightThumbnailStrokeWidth):setStrokeColor(ui.highlightColor):setBehavior(BEHAVIOR)
|
|
-- :orderAbove(w.thumb)
|
|
end
|
|
if ui.showTitles then
|
|
w.titlerect=drawing.rectangle(f):setFill(true):setFillColor(ui.highlightColor)
|
|
:setStroke(false):setRoundedRectRadii(ui.textSize/8,ui.textSize/8):setBehavior(BEHAVIOR)
|
|
-- :orderAbove(w.thumb)
|
|
w.titletext=drawing.text(f,' '):setTextStyle(ui.titleTextStyle):setBehavior(BEHAVIOR)--:orderAbove(w.titlerect)
|
|
getTitle(self,w)
|
|
end
|
|
w.hintrect=drawing.rectangle(f):setFill(true):setFillColor(ui.highlightColor)
|
|
:setStroke(true):setStrokeWidth(min(ui.textSize/10,ui.highlightThumbnailStrokeWidth)):setStrokeColor(ui.highlightColor)
|
|
:setRoundedRectRadii(ui.textSize/4,ui.textSize/4):setBehavior(BEHAVIOR)
|
|
-- :orderAbove(w.thumb)
|
|
w.hinttext=drawing.text(f,' '):setTextStyle(ui.hintTextStyle):setBehavior(BEHAVIOR)--:orderAbove(w.hintrect)
|
|
local bid=win:application():bundleID()
|
|
local icon=bid and image.imageFromAppBundle(bid) or UNAVAILABLE
|
|
w.icon=drawing.image(f,icon):setBehavior(BEHAVIOR)--:orderAbove(w.hintrect)
|
|
w.textratio=drawing.text(f,''):setTextColor{red=1,alpha=1,blue=0,green=0}
|
|
w.dirty=true
|
|
screen.totalOriginalArea=screen.totalOriginalArea+f.area
|
|
screen.windows[id]=w
|
|
self.windows[id]=w
|
|
screen.dirty=true
|
|
self.dirty=true
|
|
return startBgFitWindows(ui)
|
|
end
|
|
local function getSnapshot(w,id)
|
|
if w.thumb then w.thumb:setImage(window.snapshotForID(id) or UNAVAILABLE) end
|
|
end
|
|
local function windowUnfocused(self,win,appname,screen)
|
|
local id=win:id()
|
|
if screen.windows then
|
|
local w=screen.windows[id]
|
|
if w then return getSnapshot(w,id) end
|
|
end
|
|
end
|
|
local function windowMoved(self,win,appname,screen)
|
|
local id=win:id() local w=screen.windows[id]
|
|
if not w then return end
|
|
local frame=win:frame()
|
|
w.frame=frame w.originalFrame=frame w.frames={}--[screen]=nil
|
|
screen.dirty=true w.dirty=true
|
|
getSnapshot(w,id)
|
|
return startBgFitWindows(self.ui)
|
|
end
|
|
|
|
|
|
local function titleChanged(self,win,appname,screen)
|
|
if not self.ui.showTitles then return end
|
|
local id=win:id() local w=screen.windows[id]
|
|
if w then return getTitle(self,w) end
|
|
end
|
|
|
|
local function resume(self)
|
|
if not activeInstances[self] then self.log.i('instance stopped, ignoring resume') return self end
|
|
-- subscribe
|
|
for _,s in pairs(self.screens) do
|
|
s.callbacks={
|
|
[windowfilter.windowAllowed]=function(win,a)
|
|
self.log.vf('%s: window %s allowed',s.name,a)
|
|
return windowAllowed(self,win,a,s)
|
|
end,
|
|
[windowfilter.windowRejected]=function(win,a)
|
|
self.log.vf('%s: window %s rejected',s.name,a)
|
|
return windowRejected(self,win,a,s)
|
|
end,
|
|
[windowfilter.windowDestroyed]=function(win,a)
|
|
self.log.vf('%s: window %s destroyed',s.name,a)
|
|
return windowDestroyed(self,win,a,s)
|
|
end,
|
|
[windowfilter.windowMoved]=function(win,a)
|
|
self.log.vf('%s: window %s moved',s.name,a)
|
|
return windowMoved(self,win,a,s)
|
|
end,
|
|
[windowfilter.windowUnfocused]=function(win,a)
|
|
return windowUnfocused(self,win,a,s)
|
|
end,
|
|
[windowfilter.windowTitleChanged]=function(win,a)
|
|
return titleChanged(self,win,a,s)
|
|
end,
|
|
}
|
|
s.wf:subscribe(s.callbacks)
|
|
for _,w in ipairs(s.wf:getWindows()) do
|
|
windowAllowed(self,w,w:application():name(),s)
|
|
end
|
|
end
|
|
self.running=true
|
|
self.log.i'instance resumed'
|
|
return self
|
|
end
|
|
|
|
local function pause(self)
|
|
if not activeInstances[self] then self.log.i('instance stopped, ignoring pause') return self end
|
|
-- unsubscribe
|
|
if activeInstance==self then exitAll(self) end
|
|
for _,s in pairs(self.screens) do
|
|
s.wf:unsubscribe(s.callbacks)
|
|
end
|
|
self.running=nil
|
|
self.log.i'instance paused'
|
|
return self
|
|
end
|
|
|
|
local function deleteScreens(self)
|
|
for id,s in pairs(self.screens) do
|
|
s.wf:delete() -- remove previous wfilters
|
|
s.bg:delete()
|
|
end
|
|
self.screens={}
|
|
for id,w in pairs(self.windows) do
|
|
if w.thumb then w.thumb:delete() w.highlight:delete() end
|
|
if w.titletext then w.titletext:delete() w.titlerect:delete() end
|
|
w.hintrect:delete() w.hinttext:delete() w.icon:delete()
|
|
end
|
|
self.windows={}
|
|
end
|
|
|
|
local function makeScreens(self)
|
|
self.log.i'populating screens'
|
|
local wfLogLevel=windowfilter.getLogLevel()
|
|
deleteScreens(self)
|
|
windowfilter.setLogLevel('warning')
|
|
-- gather screens
|
|
local activeApplication=self.ui.onlyActiveApplication and true or nil
|
|
local hsscreens=screen.allScreens()
|
|
local screens={}
|
|
for _,scr in ipairs(hsscreens) do -- populate current screens
|
|
local sid,sname,sframe=scr:id(),scr:name(),scr:frame()
|
|
if sid and sname then
|
|
local wf=windowfilter.copy(self.wf,'wf-'..self.__name..'-'..sid):setDefaultFilter{}
|
|
:setOverrideFilter{visible=true,currentSpace=true,allowScreens=sid,activeApplication=activeApplication}:keepActive()
|
|
screens[sid]={name=sname,wf=wf,windows={},frame=sframe,totalOriginalArea=0,thumbnails=self.ui.showThumbnails,edge=geom.new(0,0),
|
|
bg=drawing.rectangle(sframe):setFill(true):setFillColor(self.ui.backgroundColor):setBehavior(BEHAVIOR)}
|
|
self.log.df('screen %s',scr:name())
|
|
end
|
|
end
|
|
if not next(screens) then self.log.w'no valid screens found' windowfilter.setLogLevel(wfLogLevel) return end
|
|
if self.ui.includeNonVisible then
|
|
do -- hidden windows strip
|
|
local msid=hsscreens[1]:id()
|
|
local f=screens[msid].frame
|
|
local pos=self.ui.nonVisibleStripPosition
|
|
local width=self.ui.nonVisibleStripWidth
|
|
local swidth=f[(pos=='left' or pos=='right') and 'w' or 'h']
|
|
if width<1 then width=swidth*width end
|
|
local thumbnails=self.ui.showThumbnails and width/swidth>=0.1
|
|
local invf,edge=geom.copy(f),geom.new(0,0)
|
|
-- local dock = execute'defaults read com.apple.dock "orientation"':sub(1,-2)
|
|
-- calling execute takes 100ms every time, make this a ui preference instead
|
|
if pos=='left' then f.w=f.w-width f.x=f.x+width invf.w=width edge:move(-200,0)
|
|
elseif pos=='right' then f.w=f.w-width invf.x=f.x+f.w invf.w=width edge:move(200,0)
|
|
else pos='bottom' f.h=f.h-width invf.y=f.y+f.h invf.h=width edge:move(0,200) end --bottom
|
|
local wf=windowfilter.copy(self.wf,'wf-'..self.__name..'-invisible'):setDefaultFilter{}
|
|
:setOverrideFilter{visible=false,activeApplication=activeApplication}:keepActive()
|
|
screens.inv={name='invisibleWindows',isStrip=true,wf=wf,windows={},totalOriginalArea=0,frame=invf,thumbnails=thumbnails,edge=edge,pos=pos,
|
|
bg=drawing.rectangle(invf):setFill(true):setFillColor(self.ui.nonVisibleStripBackgroundColor):setBehavior(BEHAVIOR)}
|
|
screens[msid].bg:setFrame(f)
|
|
self.log.d'invisible windows'
|
|
end
|
|
end
|
|
if self.ui.includeOtherSpaces then
|
|
local oscreens={}
|
|
for sid,screen in pairs(screens) do -- other spaces strip
|
|
if not screen.isStrip then
|
|
local f=screen.frame
|
|
local othf,edge=geom.copy(f),geom.new(0,0)
|
|
local pos=self.ui.otherSpacesStripPosition
|
|
local width=self.ui.otherSpacesStripWidth
|
|
local fwidth=f[(pos=='left' or pos=='right') and 'w' or 'h']
|
|
if width<1 then width=fwidth*width end
|
|
local thumbnails=self.ui.showThumbnails and width/fwidth>=0.1
|
|
if pos=='left' then f.w=f.w-width f.x=f.x+width othf.w=width edge:move(-200,0)
|
|
elseif pos=='right' then f.w=f.w-width othf.x=f.x+f.w othf.w=width edge:move(200,0)
|
|
elseif pos=='bottom' then f.h=f.h-width othf.y=f.y+f.h othf.h=width edge:move(0,200)
|
|
else pos='top' f.h=f.h-width othf.y=f.y othf.h=width f.y=f.y+width edge:move(0,-200) end -- top
|
|
local wf=windowfilter.copy(self.wf,'wf-'..self.__name..'-o'..sid):setDefaultFilter{}
|
|
:setOverrideFilter{visible=true,currentSpace=false,allowScreens=sid,activeApplication=activeApplication}:keepActive()
|
|
local name='other/'..screen.name
|
|
oscreens['o'..sid]={name=name,isStrip=true,wf=wf,windows={},totalOriginalArea=0,frame=othf,thumbnails=thumbnails,edge=edge,pos=pos,
|
|
bg=drawing.rectangle(othf):setFill(true):setFillColor(self.ui.otherSpacesStripBackgroundColor):setBehavior(BEHAVIOR)}
|
|
screen.bg:setFrame(f)
|
|
self.log.df('screen %s',name)
|
|
end
|
|
end
|
|
for sid,scr in pairs(oscreens) do screens[sid]=scr end
|
|
end
|
|
for _,screen in pairs(screens) do
|
|
screen.frame:move(10,10):setw(screen.frame.w-20):seth(screen.frame.h-20) -- margin
|
|
end
|
|
self.screens=screens
|
|
windowfilter.setLogLevel(wfLogLevel)
|
|
end
|
|
local function processScreensChanged()
|
|
for self in pairs(activeInstances) do makeScreens(self) end
|
|
for self in pairs(activeInstances) do resume(self) end
|
|
end
|
|
|
|
expose.screensChangedDelay=10
|
|
local function screensChanged()
|
|
log.d('screens changed, pausing active instances')
|
|
for self in pairs(activeInstances) do pause(self) end
|
|
screensChangedTimer:start()
|
|
end
|
|
|
|
|
|
local function start(self)
|
|
if activeInstances[self] then self.log.i('instance already started, ignoring') return self end
|
|
activeInstances[self]=true
|
|
if not screenWatcher then
|
|
log.i('starting global watchers')
|
|
screenWatcher=screen.watcher.new(screensChanged):start()
|
|
screensChangedTimer=timer.delayed.new(expose.screensChangedDelay,processScreensChanged)
|
|
spacesWatcher=spaces.watcher.new(spaceChanged):start()
|
|
bgFitTimer=timer.delayed.new(BG_FIT_INTERVAL,bgFitWindows)
|
|
end
|
|
self.log.i'instance started'
|
|
makeScreens(self)
|
|
return resume(self)
|
|
end
|
|
|
|
local function stop(self)
|
|
if not activeInstances[self] then self.log.i('instance already stopped, ignoring') return self end
|
|
pause(self)
|
|
deleteScreens(self)
|
|
activeInstances[self]=nil
|
|
self.log.i'instance stopped'
|
|
if not next(activeInstances) then
|
|
if screenWatcher then
|
|
log.i('stopping global watchers')
|
|
screenWatcher:stop() screenWatcher=nil
|
|
screensChangedTimer:stop() screensChangedTimer=nil
|
|
spacesWatcher:stop() spacesWatcher=nil
|
|
bgFitTimer:stop() bgFitTimer=nil
|
|
end
|
|
end
|
|
return self
|
|
end
|
|
|
|
function expose.stop(self)
|
|
if self then return stop(self) end
|
|
for i in pairs(activeInstances) do
|
|
stop(i)
|
|
end
|
|
end
|
|
-- return onScreenWindows, invisibleWindows, otherSpacesWindows
|
|
|
|
local inUiPrefs -- avoid recursion
|
|
local function setUiPrefs(self)
|
|
inUiPrefs=true
|
|
local ui=self.ui
|
|
ui.hintTextStyle={font=ui.fontName,size=ui.textSize,color=ui.textColor}
|
|
ui.titleTextStyle={font=ui.fontName,size=max(10,ui.textSize/2),color=ui.textColor,lineBreak='truncateTail'}
|
|
ui.hintHeight=drawing.getTextDrawingSize('O',ui.hintTextStyle).h
|
|
ui.titleHeight=drawing.getTextDrawingSize('O',ui.titleTextStyle).h
|
|
local hintWidth=drawing.getTextDrawingSize(ssub('MMMMMMM',1,ui.maxHintLetters+1),ui.hintTextStyle).w
|
|
ui.minWidth=hintWidth+ui.hintHeight*1.4--+padding*4
|
|
ui.minHeight=ui.hintHeight*2
|
|
-- ui.noThumbsFrameSide=ui.minWidth-- ui.textSize*4
|
|
inUiPrefs=nil
|
|
end
|
|
|
|
--- hs.expose.new([windowfilter[, uiPrefs][, logname, [loglevel]]]) -> hs.expose object
|
|
--- Constructor
|
|
--- Creates a new hs.expose instance; it can use a windowfilter to determine which windows to show
|
|
---
|
|
--- Parameters:
|
|
--- * windowfilter - (optional) if omitted or nil, use the default windowfilter; otherwise it must be a windowfilter
|
|
--- instance or constructor table
|
|
--- * uiPrefs - (optional) a table to override UI preferences for this instance; its keys and values
|
|
--- must follow the conventions described in `hs.expose.ui`; this parameter allows you to have multiple
|
|
--- expose instances with different behaviour (for example, with and without thumbnails and/or titles)
|
|
--- using different hotkeys
|
|
--- * logname - (optional) name of the `hs.logger` instance for the new expose; if omitted, the class logger will be used
|
|
--- * loglevel - (optional) log level for the `hs.logger` instance for the new expose
|
|
---
|
|
--- Returns:
|
|
--- * the new instance
|
|
---
|
|
--- Notes:
|
|
--- * by default expose will show invisible windows and (unlike the OSX expose) windows from other spaces; use
|
|
--- `hs.expose.ui` or the `uiPrefs` parameter to change these behaviours.
|
|
function expose.new(wf,uiPrefs,logname,loglevel)
|
|
if type(uiPrefs)=='string' then loglevel=logname logname=uiPrefs uiPrefs={} end
|
|
if uiPrefs==nil then uiPrefs={} end
|
|
if type(uiPrefs)~='table' then error('uiPrefs must be a table',2) end
|
|
local self = setmetatable({screens={},windows={},__name=logname or 'expose'},{__index=expose,__gc=stop})
|
|
self.log=logname and logger.new(logname,loglevel) or log
|
|
self.setLogLevel=self.log.setLogLevel self.getLogLevel=self.log.getLogLevel
|
|
if wf==nil then self.log.i('new expose instance, using default windowfilter') wf=windowfilter.default
|
|
else self.log.i('new expose instance using windowfilter instance') wf=windowfilter.new(wf) end
|
|
--uiPrefs
|
|
self.ui=setmetatable({},{
|
|
__newindex=function(t,k,v)rawset(self.ui,k,getColor(v))if not inUiPrefs then return setUiPrefs(self)end end,
|
|
__index=function(t,k)return getColor(uiGlobal[k]) end,
|
|
})
|
|
for k,v in pairs(uiPrefs) do rawset(self.ui,k,getColor(v)) end setUiPrefs(self)
|
|
-- local wfLogLevel=windowfilter.getLogLevel()
|
|
-- windowfilter.setLogLevel('warning')
|
|
-- self.wf=windowfilter.copy(wf):setDefaultFilter{} -- all windows; include fullscreen and invisible even for default wf
|
|
-- windowfilter.setLogLevel(wfLogLevel)
|
|
self.wf=wf
|
|
return start(self)
|
|
end
|
|
|
|
return expose
|