hammerspoon/extensions/window/window_switcher.lua

425 lines
18 KiB
Lua

--- === hs.window.switcher ===
---
--- Window-based cmd-tab replacement
---
--- Usage:
--- ```
--- -- set up your windowfilter
--- switcher = hs.window.switcher.new() -- default windowfilter: only visible windows, all Spaces
--- switcher_space = hs.window.switcher.new(hs.window.filter.new():setCurrentSpace(true):setDefaultFilter{}) -- include minimized/hidden windows, current Space only
--- switcher_browsers = hs.window.switcher.new{'Safari','Google Chrome'} -- specialized switcher for your dozens of browser windows :)
---
--- -- bind to hotkeys; WARNING: at least one modifier key is required!
--- hs.hotkey.bind('alt','tab','Next window',function()switcher:next()end)
--- hs.hotkey.bind('alt-shift','tab','Prev window',function()switcher:previous()end)
---
--- -- alternatively, call .nextWindow() or .previousWindow() directly (same as hs.window.switcher.new():next())
--- hs.hotkey.bind('alt','tab','Next window',hs.window.switcher.nextWindow)
--- -- you can also bind to `repeatFn` for faster traversing
--- hs.hotkey.bind('alt-shift','tab','Prev window',hs.window.switcher.previousWindow,nil,hs.window.switcher.previousWindow)
--- ```
local type,pairs=type,pairs
local min,max=math.min,math.max
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 eventtap,timer,hotkey=require'hs.eventtap',require'hs.timer',require'hs.hotkey'
local timer=require'hs.timer'
local checkMods=require'hs.eventtap'.checkKeyboardModifiers
local logger=require'hs.logger'
local log=logger.new'wswitcher'
local switcher={setLogLevel=log.setLogLevel,getLogLevel=log.getLogLevel} -- module
local UNAVAILABLE=image.imageFromName'NSStopProgressTemplate'
local snapshots=setmetatable({},{__mode='kv'})
local SNAPSHOT_EXPIRY=60 --1m
local function getSnapshot(id)
if not id then return UNAVAILABLE end
local sn,now=snapshots[id],timer.secondsSinceEpoch()
if not sn or sn[2]+SNAPSHOT_EXPIRY<now then snapshots[id]={window.snapshotForID(id) or UNAVAILABLE,now} end
return snapshots[id][1]
end
local icons=setmetatable({},{__mode='kv'})
local function getIcon(bundle)
if not bundle then return UNAVAILABLE
elseif not icons[bundle] then icons[bundle]=image.imageFromAppBundle(bundle) or UNAVAILABLE end
return icons[bundle]
end
--- hs.window.switcher.ui
--- Variable
--- Allows customization of the switcher behaviour and user interface
---
--- This table contains variables that you can change to customize the behaviour of the switcher and the look of the UI.
--- To have multiple switcher 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.window.switcher.ui.textColor = {0.9,0.9,0.9}`
--- * `hs.window.switcher.ui.fontName = 'Lucida Grande'`
--- * `hs.window.switcher.ui.textSize = 16` - in screen points
--- * `hs.window.switcher.ui.highlightColor = {0.8,0.5,0,0.8}` - highlight color for the selected window
--- * `hs.window.switcher.ui.backgroundColor = {0.3,0.3,0.3,1}`
--- * `hs.window.switcher.ui.onlyActiveApplication = false` -- only show windows of the active application
--- * `hs.window.switcher.ui.showTitles = true` - show window titles
--- * `hs.window.switcher.ui.titleBackgroundColor = {0,0,0}`
--- * `hs.window.switcher.ui.showThumbnails = true` - show window thumbnails
--- * `hs.window.switcher.ui.thumbnailSize = 128` - size of window thumbnails in screen points
--- * `hs.window.switcher.ui.showSelectedThumbnail = true` - show a larger thumbnail for the currently selected window
--- * `hs.window.switcher.ui.selectedThumbnailSize = 384`
--- * `hs.window.switcher.ui.showSelectedTitle = true` - show larger title for the currently selected window
-- * `hs.window.switcher.ui.closeModeModifier = 'shift'` - "close mode" engaged while pressed (or 'cmd','ctrl','alt')
-- * `hs.window.switcher.ui.closeModeBackgroundColor = {0.7,0.1,0.1,1}` - background color while "close mode" is engaged
-- * `hs.window.switcher.ui.minimizeModeModifier = 'alt'` - "minimize mode" engaged while pressed
-- * `hs.window.switcher.ui.minimizeModeBackgroundColor = {0.1,0.2,0.3,1}` - background color while "minimize mode" is engaged
local uiGlobal = {
textColor={1,1,1},
fontName='Lucida Grande',
textSize=16,
backgroundColor={0.3,0.3,0.3,1},
highlightColor={0.8,0.5,0,0.8},
showTitles=true,
titleBackgroundColor={0,0,0},
showThumbnails=true,
thumbnailSize=128,
showSelectedThumbnail=true,
selectedThumbnailSize=384,
showSelectedTitle=true,
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
switcher.ui=setmetatable({},{
__newindex=function(_,k,v) uiGlobal[k]=getColor(v) end,
__index=function(_,k)return getColor(uiGlobal[k])end,
})
local function setFrames(nwindows,drawings,ui)
local haveThumbs,haveTitles=ui.showThumbnails,ui.showTitles
local titleHeight=ui.titleHeight
local screenFrame=drawings.screenFrame
if not drawings.screenFrame then drawings.screenFrame=screen.mainScreen():frame() screenFrame=drawings.screenFrame end
local padding=ui.thumbnailSize*0.1
local size=min(ui.thumbnailSize,(screenFrame.w-padding*(nwindows+1))/nwindows)
padding=size*0.1
local titlePadding=haveTitles and padding or 0
local selSize=ui.selectedThumbnailSize
local selPadding=ui.selectedPadding
local selTitleHeight=ui.selectedTitleHeight
local selHeight=selSize+selPadding*2+selTitleHeight
local bgframe=geom(0,0,(size+padding)*nwindows+padding,size+padding+titleHeight+titlePadding):setcenter(screenFrame.center)
bgframe:move(0,max(0,screenFrame.y+selHeight-bgframe.y))
drawings.background:setFrame(bgframe):setRoundedRectRadii(padding,padding)
for i=1,nwindows do
local dr=drawings[i]
local thumbFrame=geom(bgframe.x,bgframe.y,size,size):move(padding+(size+padding)*(i-1),padding/2+titleHeight+titlePadding)
local iconFrame=geom.copy(thumbFrame)
if haveThumbs then
dr.thumb:setFrame(thumbFrame)
iconFrame:setw(size/2):seth(size/2):move(size/4,size/2)
end
dr.icon:setFrame(iconFrame)
if haveTitles then
dr.titleFrame=geom.copy(thumbFrame):seth(titleHeight):move(0,-titlePadding-titleHeight)
end
local selFrame=geom.copy(thumbFrame):setw(selSize+selPadding*2):seth(selSize+selPadding*2+selTitleHeight)
:setcenter(thumbFrame.center):move(0,-selSize/2-size/2-padding*1.5-selPadding-selTitleHeight)
if selFrame.x<screenFrame.x then selFrame.x=screenFrame.x
elseif selFrame.x2>screenFrame.x2 then selFrame.x=screenFrame.x2-selFrame.w end
dr.selRectFrame=selFrame
dr.selThumbFrame=geom.copy(selFrame):setw(selSize):seth(selSize):move(selPadding,selPadding+selTitleHeight)
dr.selIconFrame=geom.copy(dr.selThumbFrame):setw(selSize/2):seth(selSize/2):move(selSize/4,selSize/2+selPadding)
dr.highlightFrame=geom.copy(thumbFrame):move(-padding/2,-padding/2-titleHeight-titlePadding)
:setw(size+padding):seth(size+padding+titleHeight+titlePadding)
dr.selTitleFrame=geom.copy(selFrame):seth(selTitleHeight)
end
drawings.size=size
end
local function draw(windows,drawings,ui)
if ui.showSelectedThumbnail then
drawings.selRect:show()
drawings.selThumb:show()
drawings.selIcon:show()
end
if ui.showSelectedTitle then
drawings.selTitleRect:show() drawings.selTitleText:show()
end
local haveThumbs,haveTitles=ui.showThumbnails,ui.showTitles
drawings.background:show()
drawings.highlightRect:show()
local size=drawings.size
for i=1,#windows do
local win,dr=windows[i],drawings[i]
if haveThumbs then dr.thumb:setImage(getSnapshot(win:id())):show() end
dr.icon:setImage(getIcon(win:application():bundleID())):show()
if haveTitles then
local title=win:title() or ' '
local titleFrame=dr.titleFrame
local titleWidth=drawing.getTextDrawingSize(title,ui.titleTextStyle).w*1.1
if titleWidth<titleFrame.w then titleFrame:setw(titleWidth):move((size-titleWidth)/2,0) end
dr.titleRect:setFrame(titleFrame):show()
dr.titleText:setFrame(titleFrame):setText(title):show()
end
end
end
local function showSelected(selected,windows,drawings,ui)
local win,dr=windows[selected],drawings[selected]
local title=win:title() or ' '
drawings.highlightRect:setFrame(dr.highlightFrame)
drawings.selRect:setFrame(dr.selRectFrame)
if ui.showSelectedThumbnail then
drawings.selThumb:setImage(getSnapshot(win:id())):setFrame(dr.selThumbFrame)
drawings.selIcon:setImage(getIcon(win:application():bundleID())):setFrame(dr.selIconFrame)
end
if ui.showSelectedTitle then
drawings.selTitleRect:setFrame(dr.selTitleFrame)
drawings.selTitleText:setText(title):setFrame(dr.selTitleFrame)
end
end
--TODO esc to quit; w to close; m to minimize (needs eventtap, which should also replace the checkmods timer)
local function exit(self)
local selected=self.selected
local windows,drawings,ui=self.windows,self.drawings,self.ui
self.windows=nil
self.selected=nil
self.modsTimer=nil
if not selected then return end
self.drawDelayed:stop()
local haveThumbs,haveTitles=ui.showThumbnails,ui.showTitles
drawings.background:hide()
drawings.highlightRect:hide()
for i=1,#windows do
local dr=drawings[i]
dr.icon:hide()
if haveThumbs then dr.thumb:hide() end
if haveTitles then dr.titleRect:hide() dr.titleText:hide() end
end
drawings.selRect:hide()
if ui.showSelectedThumbnail then drawings.selThumb:hide() drawings.selIcon:hide() end
if ui.showSelectedTitle then drawings.selTitleRect:hide() drawings.selTitleText:hide() end
log.i('focusing',windows[selected])
windows[selected]:unminimize()
-- if windows[selected]:application():bundleID()~='com.apple.finder' then
-- windows[selected]:focus()
-- else
timer.doAfter(0.15,function()windows[selected]:focus()end) -- el cap bugs out (desktop "floats" on top) if done directly
-- end
end
local MODS_INTERVAL=0.05 -- recheck for (lack of) mod keys after this interval
local function modsPressed() return checkMods(true)._raw>0 end
local function show(self,dir)
local windows,drawings,ui=self.windows,self.drawings,self.ui
if not windows then
windows=self.wf:getWindows(windowfilter.sortByFocusedLast)
self.windows=windows
end
local nwindows=#windows or 0
if nwindows==0 then self.log.i('no windows') return end
local selected=self.selected
if not selected then -- fresh invocation, prep everything
if nwindows>#drawings then -- need new drawings
self.log.vf('found %d new windows',nwindows-#drawings)
local tempframe=geom(0,0,1,1)
for n=#drawings+1,nwindows do
local t={icon=drawing.image(tempframe,UNAVAILABLE)}
if ui.showThumbnails then t.thumb=drawing.image(tempframe,UNAVAILABLE) end
if ui.showTitles then
t.titleRect=drawing.rectangle(tempframe):setRoundedRectRadii(ui.titleRectRadius,ui.titleRectRadius)
:setFillColor(ui.titleBackgroundColor):setStroke(false)
t.titleText=drawing.text(tempframe,' '):setTextStyle(ui.titleTextStyle)
end
drawings[n]=t
end
end
if nwindows~=drawings.lastn then -- they all must move
setFrames(nwindows,drawings,ui)
drawings.lastn=nwindows
end
self.drawDelayed=timer.doAfter(0.2,function()
draw(windows,drawings,ui)
end)
self.modsTimer=timer.waitWhile(modsPressed,function()exit(self)end,MODS_INTERVAL)
selected=1
self.log.df('activated, %d windows',nwindows)
elseif self.drawDelayed:running() then self.drawDelayed:stop() draw(windows,drawings,ui)
end
-- now also for subsequent invocations
selected=selected+dir
if selected<=0 then selected=nwindows
elseif selected>nwindows then selected=1 end
self.log.vf('window #%d selected',selected)
self.selected=selected
showSelected(selected,windows,drawings,ui)
end
--- hs.window.switcher:next()
--- Method
--- Shows the switcher instance (if not yet visible) and selects the next window
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
---
--- Notes:
--- * the switcher will be dismissed (and the selected window focused) when all modifier keys are released
function switcher:next() return show(self,1) end
--- hs.window.switcher:previous()
--- Method
--- Shows the switcher instance (if not yet visible) and selects the previous window
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
---
--- Notes:
--- * the switcher will be dismissed (and the selected window focused) when all modifier keys are released
function switcher:previous() return show(self,-1) end
local defaultSwitcher
local function makeDefault()
defaultSwitcher=switcher.new(nil,nil,'wswtch-def')
return defaultSwitcher
end
--- hs.window.switcher.nextWindow()
--- Function
--- Shows the switcher (if not yet visible) and selects the next window
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
---
--- Notes:
--- * the switcher will be dismissed (and the selected window focused) when all modifier keys are released
function switcher.nextWindow() return show(defaultSwitcher or makeDefault(),1) end
--- hs.window.switcher.previousWindow()
--- Function
--- Shows the switcher (if not yet visible) and selects the previous window
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
---
--- Notes:
--- * the switcher will be dismissed (and the selected window focused) when all modifier keys are released
function switcher.previousWindow() return show(defaultSwitcher or makeDefault(),-1) end
local function gc(self)
self.log.i('windowswitcher instance deleted')
self.screenWatcher:stop()
end
local inUiPrefs --recursion avoidance semaphore
local function setUiPrefs(self)
inUiPrefs=true
local ui=self.ui
if ui.showTitles then
ui.titleTextStyle={font=ui.fontName,size=ui.textSize,color=ui.textColor,lineBreak='truncateTail'}
ui.titleRectRadius=ui.textSize/4
ui.titleHeight=drawing.getTextDrawingSize('O',ui.titleTextStyle).h
else ui.titleHeight=0 end
local selectedRectRadius=0
local selectedTitleTextStyle={font=ui.fontName,size=math.floor(ui.textSize*2),color=ui.textColor,lineBreak='truncateTail',alignment='center'}
if ui.showSelectedTitle then
ui.selectedTitleHeight=drawing.getTextDrawingSize('O',selectedTitleTextStyle).h
selectedRectRadius=selectedTitleTextStyle.size/4
else ui.selectedTitleHeight=0 end
if ui.showSelectedThumbnail then
ui.selectedPadding=ui.selectedThumbnailSize/10
else ui.selectedThumbnailSize,ui.selectedPadding=0,0 end
ui.selectedHeight=ui.selectedThumbnailSize+ui.selectedPadding*2+ui.selectedTitleHeight
self.ui=ui
local tempframe=geom.new(-5,0,1,1)
local drawings=self.drawings
if drawings.background then
drawings.background:delete() drawings.highlightRect:delete()
drawings.selRect:delete() drawings.selThumb:delete() drawings.selIcon:delete()
drawings.selTitleRect:delete() drawings.selTitleText:delete()
end
drawings.background=drawing.rectangle(tempframe):setFillColor(ui.backgroundColor):setStroke(false)
drawings.highlightRect=drawing.rectangle(tempframe):setFillColor(ui.highlightColor):setStroke(false)
drawings.selRect=drawing.rectangle(tempframe):setRoundedRectRadii(selectedRectRadius,selectedRectRadius)
:setStroke(false):setFillColor(ui.backgroundColor)
drawings.selThumb=drawing.image(tempframe,UNAVAILABLE)
drawings.selIcon=drawing.image(tempframe,UNAVAILABLE)
drawings.selTitleRect=drawing.rectangle(tempframe):setFillColor(ui.titleBackgroundColor):setStroke(false)
:setRoundedRectRadii(selectedRectRadius,selectedRectRadius)
drawings.selTitleText=drawing.text(tempframe,' '):setTextStyle(selectedTitleTextStyle)
inUiPrefs=nil
end
--- hs.window.switcher.new([windowfilter[, uiPrefs][, logname, [loglevel]]]) -> hs.window.switcher object
--- Constructor
--- Creates a new switcher 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.window.switcher.ui`; this parameter allows you to have multiple
--- switcher 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 switcher; if omitted, the class logger will be used
--- * loglevel - (optional) log level for the `hs.logger` instance for the new switcher
---
--- Returns:
--- * the new instance
function switcher.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({drawings={}},{__index=switcher,__gc=gc})
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 windowswitcher instance, using default windowfilter') self.wf=windowfilter.default
else self.log.i('new windowswitcher instance using windowfilter instance') self.wf=windowfilter.new(wf) end
--uiPrefs
self.ui=setmetatable({},{
__newindex=function(_,k,v)rawset(self.ui,k,getColor(v)) return not inUiPrefs and setUiPrefs(self)end,
__index=function(_,k)return getColor(uiGlobal[k]) end,
})
for k,v in pairs(uiPrefs) do rawset(self.ui,k,getColor(v)) end setUiPrefs(self)
self.screenWatcher=screen.watcher.new(function() self.drawings.lastn=-1 self.drawings.screenFrame=nil end):start()
return self
end
return switcher