903 lines
40 KiB
Lua
903 lines
40 KiB
Lua
--- === hs.window.layout ===
|
|
---
|
|
--- **WARNING**: EXPERIMENTAL MODULE. DO **NOT** USE IN PRODUCTION.
|
|
--- This module is *for testing purposes only*. It can undergo breaking API changes or *go away entirely* **at any point and without notice**.
|
|
--- (Should you encounter any issues, please feel free to report them on https://github.com/Hammerspoon/hammerspoon/issues
|
|
--- or #hammerspoon on irc.libera.chat)
|
|
---
|
|
--- Window management
|
|
---
|
|
--- Windowlayouts work by selecting certain windows via windowfilters and arranging them onscreen according to specific rules.
|
|
---
|
|
--- A **layout** is composed of a list of rules and, optionally, a screen arrangement definition.
|
|
--- Rules within a layout are evaluated in order; once a window is acted upon by a rule, subsequent rules will not affect it further.
|
|
--- A **rule** needs a **windowfilter**, producing a dynamic list of windows (the "window pool") to which the rule is applied,
|
|
--- and a list of commands, evaluated in order.
|
|
--- A **command** acts on one or more of the windows, and is composed of:
|
|
--- * an **action**, it can be
|
|
--- - `move`: moves the window(s) to a specified onscreen rect (if the action is omitted, `move` is assumed)
|
|
--- - `minimize`, `maximize`, `fullscreen`
|
|
--- - `tile`, `fit`: tiles the windows onto a specified rect, using `hs.window.tiling.tileWindows()`; for `fit`, the
|
|
--- `preserveRelativeArea` parameter will be set to true
|
|
--- - `hide`, `unhide`: hides or unhides the window's application (like when using cmd-h)
|
|
--- - `noaction`: skip action on the window(s)
|
|
--- * a **maxn** number, indicating how many windows from this rule's window pool will be affected (at most) by this command;
|
|
--- if omitted (or if explicitly the string `all`) all the remaining windows will be processed by this command; processed
|
|
--- windows are "consumed" and are excluded from the window pool for subsequent commands in this rule, and from subsequent rules
|
|
--- * a **selector**, describing the sort order used to pick the first *maxn* windows from the window pool for this command;
|
|
--- it can be one of `focused` (pick *maxn* most recently focused windows), `frontmost` (pick the recent focused window if its
|
|
--- application is frontmost applicaion, otherwise the command will be skipped), `newest` (most recently created), `oldest`
|
|
--- (least recently created), or `closest` (pick the *maxn* windows that are closest to the destination rect); if omitted,
|
|
--- defaults to `closest` for move, tile and fit, and `newest` for everything else
|
|
--- * an `hs.geometry` *size* (only valid for tile and fit) indicating the desired optimal aspect ratio for the tiled windows;
|
|
--- if omitted, defaults to 1x1 (i.e. square windows)
|
|
--- * for move, tile and fit, an `hs.geometry` *rect*, or a *unit rect* plus a *screen hint* (for `hs.screen.find()`),
|
|
--- indicating the destination rect for the command
|
|
--- * for fullscreen and maximize, a *screen hint* indicating the desired screen; if omitted, uses the window's current screen
|
|
---
|
|
--- You should place higher-priority rules (with highly specialized windowfilters) first, and "fallback" rules
|
|
--- (with more generic windowfilters) last; similarly, *within* a rule, you should have commands for the more "important"
|
|
--- (i.e. relevant to your current workflow) windows first (move, maximize...) and after that deal with less prominent
|
|
--- windows, if any remain, e.g. by placing them out of the way (minimize).
|
|
--- `unhide` and `hide`, if used, should usually go into their own rules (with a windowfilter that allows invisible windows
|
|
--- for `unhide`) that come *before* other rules that deal with actual window placement - unlike the other actions,
|
|
--- they don't "consume" windows making them unavailable for subsequent rules, as they act on applications.
|
|
---
|
|
--- In order to avoid dealing with deeply nested maps, you can define a layout in your scripts via a list, where each element
|
|
--- (or row) denotes a rule; in turn every rule can be a simplified list of two elements:
|
|
--- - a windowfilter or a constructor argument table for one (see `hs.window.filter.new()` and `hs.window.filter:setFilters()`)
|
|
--- - a single string containing all the commands (action and parameters) in order; actions and selectors can be shortened to
|
|
--- 3 characters; all tokens must be separated by spaces (do not use spaces inside `hs.geometry` constructor strings);
|
|
--- for greater clarity you can separate commands with `|` (pipe character)
|
|
---
|
|
--- Some command string examples:
|
|
--- - `"move 1 [0,0,50,50] -1,0"` moves the closest window to the topleft quadrant of the left screen
|
|
--- - `"max 0,0"` maximizes all the windows onto the primary screen, one on top of another
|
|
--- - `"move 1 foc [0,0,30,100] 0,0 | tile all foc [30,0,100,100] 0,0"` moves the most recently focused window to the left third,
|
|
--- and tiles the remaining windows onto the right side, keeping the most recently focused on top and to the left
|
|
--- - `"1 new [0,0,50,100] 0,0 | 1 new [50,0,100,100] 0,0 | min"` divides the primary screen between the two newest windows
|
|
--- and minimizes any other windows
|
|
---
|
|
--- Each layout can work in "passive" or "active" modes; passive layouts must be triggered manually (via `hs.hotkey.bind()`,
|
|
--- `hs.menubar`, etc.) while active layouts continuously keep their rules enforced (see `hs.window.layout:start()`
|
|
--- for more information); in general you should avoid having multiple active layouts targeting the same windows, as the
|
|
--- results will be unpredictable (if such a situation is detected, you'll see an error in the Hammerspoon console); you
|
|
--- *can* have multiple active layouts, but be careful to maintain a clear "separation of concerns" between their respective windowfilters.
|
|
---
|
|
--- Each layout can have an associated screen configuration; if so, the layout will only be valid while the current screen
|
|
--- arrangement satisfies it; see `hs.window.layout:setScreenConfiguration()` for more information.
|
|
|
|
--TODO full examples in the wiki
|
|
|
|
local application = require 'hs.application'
|
|
|
|
local pairs,ipairs,next,type,pcall=pairs,ipairs,next,type,pcall
|
|
local floor=math.floor
|
|
local sformat,ssub,gmatch,gsub=string.format,string.sub,string.gmatch,string.gsub
|
|
local tonumber,tostring=tonumber,tostring
|
|
local tpack,tremove,tconcat=table.pack,table.remove,table.concat
|
|
|
|
local window=hs.window
|
|
local windowfilter=require'hs.window.filter'
|
|
local tileWindows=require'hs.window.tiling'.tileWindows
|
|
local geom,screen,timer,eventtap=require'hs.geometry',require'hs.screen',require'hs.timer',require'hs.eventtap'
|
|
|
|
local logger=require'hs.logger'
|
|
local log=logger.new'wlayout'
|
|
local layout={} -- module and class
|
|
layout.setLogLevel=log.setLogLevel
|
|
layout.getLogLevel=log.getLogLevel
|
|
|
|
local winbuf,appbuf={},{} -- action buffers for windows and apps
|
|
local rulebuf={} -- buffer for rules to apply
|
|
local screenCache={} -- memoize screen.find() on current screens
|
|
|
|
local activeInstances={} -- wlayouts currently in 'active mode'
|
|
local screenInstances={} -- wlayouts that care about screen configuration (they become active/inactive as screens change)
|
|
|
|
|
|
local function tlen(t)local i,e=0,next(t) while e do i,e=i+1,next(t,e) end return i end
|
|
local function errorf(s,...) local args=tpack(...) error(sformat(s,...),args[#args]+1) end
|
|
local function strip(s) return type(s)=='string' and s:gsub('%s+','') or s end
|
|
|
|
local MOVE,TILE,FIT,HIDE,UNHIDE,MINIMIZE,MAXIMIZE,FULLSCREEN,RESTORE='move','tile','fit','hide','unhide','minimize','maximize','fullscreen','restore'
|
|
local NOACTION='noaction'
|
|
local CREATEDLAST,CREATEDFIRST,FOCUSEDLAST,CLOSEST,FRONTMOST='createdLast','created','focusedLast','closest','frontmost'
|
|
local ACTIONS={mov=MOVE,fra=MOVE,til=TILE,fit=FIT,hid=HIDE,unh=UNHIDE,sho=UNHIDE,max=MAXIMIZE,ful=FULLSCREEN,fs=FULLSCREEN,min=MINIMIZE,res=RESTORE,noa=NOACTION}
|
|
local SELECTORS={cre=CREATEDLAST,new=CREATEDLAST,old=CREATEDFIRST,foc=FOCUSEDLAST,clo=CLOSEST,pos=CLOSEST,fro=FRONTMOST}
|
|
|
|
local function getaction(s,i)
|
|
if type(s)~='string' then return nil,i end
|
|
local r=ACTIONS[ssub(s,1,3)] return r,r and i+1 or i
|
|
end
|
|
local function getmaxnumber(n,i)
|
|
if n=='all' then return nil,i+1 end
|
|
n=tonumber(n) if type(n)=='number' and n<1000 then return n,i+1 else return nil,i end
|
|
end
|
|
local function getselector(s,i)
|
|
if type(s)~='string' then return nil,i end
|
|
local r=SELECTORS[ssub(s,1,3)] return r,r and i+1 or i
|
|
end
|
|
local function getaspect(a,i)
|
|
if type(a)=='string' then
|
|
if a:sub(1,3)=='hor' or a=='row' then return 0.01,i+1
|
|
elseif a:sub(1,3)=='ver' or a:sub(1,3)=='col' then return 100,i+1 end
|
|
end
|
|
-- if type(a)=='number' then return a,i+1 end
|
|
local ok,res=pcall(geom.new,a)
|
|
if ok and res:type()=='size' then return res.aspect,i+1 else return nil,i end
|
|
end
|
|
local function getrect(r,i)
|
|
local ok,res=pcall(geom.new,r)
|
|
if ok and geom.type(res)=='rect' then return res,i+1 else return nil,i end
|
|
end
|
|
local function getunitrect(r,i)
|
|
local ok,res=pcall(geom.new,r)
|
|
if ok and geom.type(res)=='unitrect' then return res,i+1 else return nil,i end
|
|
end
|
|
|
|
local function validatescreen(s,i)
|
|
if getaction(s,i) then return nil,i
|
|
elseif type(s)=='number' and s>=1000 then return s,i+1
|
|
elseif type(s)=='string' or type(s)=='table' then
|
|
local ok,res=pcall(geom.new,s) --disallow full frame, as it could be mistaken for the next cmd (implicit 'move')
|
|
if ok then
|
|
local typ=geom.type(res)
|
|
if typ=='point' or typ=='size' then return s,i+1 else return nil,i end
|
|
end
|
|
if type(s)=='string' then return s,i+1 else return nil,i end
|
|
end
|
|
return nil,i
|
|
end
|
|
local function screenstr(s)
|
|
if type(s)=='number' then return s
|
|
elseif type(s)=='string' or type(s)=='table' then
|
|
local ok,res=pcall(geom.new,s)
|
|
if ok then return res.string end
|
|
if type(s)=='string' then return s end
|
|
end
|
|
end
|
|
|
|
local function validateCommand(command,ielem,icmd,irule,l)
|
|
local idx=irule..'.'..icmd
|
|
if command.irule~=irule or command.icmd~=icmd then errorf('invalid indices %d.%d, %s expected',command.irule,command.icmd,idx,6) end
|
|
local function error(s) return errorf('invalid %s, token %d in rule %s',s,ielem,idx,7) end
|
|
local action=getaction(command.action,0)
|
|
if not action then error'action' end
|
|
local logs=sformat('rule %d.%d: %s',irule,icmd,action)
|
|
-- if action==RESTORE then command.max=999 return command,ielem,icmd+1 end
|
|
if command.max then
|
|
if type(command.max)~='number' or floor(command.max)~=command.max or command.max<1 or command.max>999 then error'max number' end
|
|
-- if command.action~=UNHIDE and command.max<1 then error'max number' end --allow "unhide 0"
|
|
logs=logs..' '..command.max
|
|
else logs=logs..' all' end
|
|
if command.select then
|
|
if not getselector(command.select,0) then error'selector'
|
|
else logs=logs..' '..command.select end
|
|
end
|
|
if action==MAXIMIZE or action==FULLSCREEN or action==NOACTION then
|
|
if command.screen then
|
|
if not validatescreen(command.screen,ielem) then error'screen'end
|
|
logs=logs..' screen='..screenstr(command.screen)
|
|
end
|
|
elseif action==MOVE or action==TILE or action==FIT then
|
|
if action==TILE or action==FIT then
|
|
if command.aspect then
|
|
if type(command.aspect)~='number' or command.aspect<=0 or command.aspect==command.aspect/2 then error'aspect'
|
|
else logs=logs..' aspect='..sformat('%.2f',command.aspect) end
|
|
end
|
|
end
|
|
if command.rect then
|
|
local rect=geom.new(command.rect)
|
|
if geom.type(rect)~='rect' then error'rect'
|
|
else logs=logs..' rect='..rect.string end
|
|
else
|
|
if not command.unitrect then errorf('need rect or unitrect, token %d in rule %s',ielem,idx,6) end
|
|
local unitrect=geom.new(command.unitrect)
|
|
if geom.type(unitrect)~='unitrect' then error'unitrect'
|
|
else logs=logs..' unitrect='..unitrect.string end
|
|
if not validatescreen(command.screen,ielem) then error'screen'end
|
|
logs=logs..' screen='..screenstr(command.screen)
|
|
end
|
|
elseif action==HIDE or action==MINIMIZE or action==UNHIDE or action==RESTORE then
|
|
if getselector(command.select,0)==CLOSEST then error'selector' end
|
|
else error'action' end
|
|
|
|
l.i(logs)
|
|
return command,ielem,icmd+1
|
|
end
|
|
|
|
local function parseCommand(rule,ielem,icmd,irule,l)
|
|
if type(rule[ielem])=='table' and rule[ielem].action then
|
|
return validateCommand(rule[ielem],ielem,icmd,irule,l),ielem+1,icmd+1
|
|
end
|
|
local r={irule=irule,icmd=icmd}
|
|
r.action,ielem=getaction(rule[ielem],ielem)
|
|
--optional number of windows to process for this cmd
|
|
r.max,ielem=getmaxnumber(rule[ielem],ielem)
|
|
-- r.max=r.max or 999
|
|
if not r.action then r.action=MOVE
|
|
elseif r.action==RESTORE then return validateCommand(r,ielem,icmd,irule,l) end
|
|
r.select,ielem=getselector(rule[ielem],ielem)
|
|
if not r.select then
|
|
if r.action==MOVE or r.action==TILE or r.action==FIT then r.select=CLOSEST
|
|
else r.select=CREATEDLAST end
|
|
end
|
|
if r.action==HIDE or r.action==MINIMIZE or r.action==UNHIDE then
|
|
return validateCommand(r,ielem,icmd,irule,l)
|
|
elseif r.action==FULLSCREEN or r.action==MAXIMIZE then
|
|
r.screen,ielem=validatescreen(rule[ielem],ielem)
|
|
return validateCommand(r,ielem,icmd,irule,l)
|
|
end
|
|
-- move or tile
|
|
if r.action==TILE or r.action==FIT then
|
|
-- optional aspect
|
|
r.aspect,ielem=getaspect(rule[ielem],ielem)
|
|
end
|
|
-- now rect or unitrect+screen
|
|
r.rect,ielem=getrect(rule[ielem],ielem)
|
|
if not r.rect then
|
|
r.unitrect,ielem=getunitrect(rule[ielem],ielem)
|
|
r.screen,ielem=validatescreen(rule[ielem],ielem)
|
|
end
|
|
validateCommand(r,ielem,icmd,irule,l)
|
|
-- if (r.action==MINIMIZE or r.action==HIDE) and elemi<=#row then error(r.action..' must be the last action in a rule',2)end
|
|
return r,ielem,icmd+1
|
|
end
|
|
|
|
local function getwf(wf,idx,logname,loglevel) return windowfilter.new(wf,'r'..idx..'-'..(logname or 'wlayout'),loglevel) end
|
|
local function wferror(wfkey,irule,res)
|
|
errorf('element %s in rule %d must be a windowfilter object or valid constructor argument\n%s',wfkey,irule,res,5)
|
|
end
|
|
local function parseRule(self,rule,irule)
|
|
local logname,loglevel=self.logname,self.loglevel
|
|
local r={windowlayout=self,irule=irule}
|
|
log.df('parsing rule %d, getting windowfilter',irule)
|
|
local ok,res,wfkey
|
|
if rule.windowfilter then
|
|
wfkey='windowfilter'
|
|
ok,res=pcall(getwf,rule.windowfilter,irule,logname,loglevel)
|
|
if not ok then wferror(wfkey,irule,res,4)
|
|
else r.windowfilter=res end
|
|
else
|
|
for k,_ in pairs(rule) do
|
|
if type(k)=='string' then wfkey,ok,res=k,pcall(getwf,{[k]=rule[k]},irule,logname,loglevel) break end
|
|
end
|
|
if ok then r.windowfilter=res
|
|
elseif ok==false then wferror(wfkey,irule,res,4)
|
|
elseif ok==nil then
|
|
wfkey,ok,res=1,pcall(getwf,rule[1],irule,logname,loglevel)
|
|
if not ok then wferror(wfkey,irule,res,4) end
|
|
r.windowfilter=res
|
|
end
|
|
end
|
|
-- hs.assert(r.windowfilter,'invalid rule windowfilter',rule)
|
|
local split,slog={},{}
|
|
for i=(wfkey==1 and 2 or 1),#rule do
|
|
local elem=rule[i]
|
|
if type(elem)=='string' then
|
|
elem=gsub(elem,'%s+[|>/-,]%s+',' ')
|
|
for s in gmatch(elem,'%s*%g+%s*') do
|
|
split[#split+1]=strip(s)
|
|
slog[#slog+1]=strip(s)
|
|
end
|
|
else
|
|
split[#split+1]=elem
|
|
slog[#slog+1]=tostring(elem)
|
|
end
|
|
end
|
|
log.vf('tokenized rule %d: %s',irule,tconcat(slog,'|'))
|
|
local ielem,icmd,cmd=1,1
|
|
while ielem<=#split do
|
|
cmd,ielem,icmd=parseCommand(split,ielem,icmd,irule,log)
|
|
cmd.windowlayout=self
|
|
r[#r+1]=cmd
|
|
end
|
|
return r
|
|
end
|
|
|
|
local function parseRules(rules,self)
|
|
local r={}
|
|
for _,row in ipairs(rules) do
|
|
if type(row)~='table' then rules={rules} break end
|
|
end
|
|
self.log.vf('will parse %d rules',#rules)
|
|
for irule=1,#rules do
|
|
r[irule]=parseRule(self,rules[irule],irule)
|
|
end
|
|
return r
|
|
end
|
|
|
|
--- hs.window.layout.new(rules[,logname[,loglevel]]) -> hs.window.layout object
|
|
--- Constructor
|
|
--- Creates a new hs.window.layout instance
|
|
---
|
|
--- Parameters:
|
|
--- * rules - a table containing the rules for this windowlayout (see the module description); additionally, if a special key `screens`
|
|
--- is present, its value must be a valid screen configuration as per `hs.window.layout:setScreenConfiguration()`
|
|
--- * logname - (optional) name of the `hs.logger` instance for the new windowlayout; if omitted, the class logger will be used
|
|
--- * loglevel - (optional) log level for the `hs.logger` instance for the new windowlayout
|
|
---
|
|
--- Returns:
|
|
--- * a new windowlayout instance
|
|
local function __tostring(self) return sformat('hs.window.layout: %s (%s)',self.logname or '...',self.__address) end
|
|
function layout.new(rules,logname,loglevel)
|
|
if type(rules)~='table' then error('rules must be a table',2)end
|
|
local o={log=logname and logger.new(logname,loglevel) or log,logname=logname,loglevel=loglevel}
|
|
o.__address=gsub(tostring(o),'table: ','')
|
|
setmetatable(o,{__index=layout,__tostring=__tostring,__gc=layout.delete})
|
|
if logname then o.setLogLevel=o.log.setLogLevel o.getLogLevel=o.log.getLogLevel end
|
|
local mt=getmetatable(rules)
|
|
if mt and mt.__index==layout then
|
|
o.log.i('new windowlayout copy')
|
|
rules=rules.rules
|
|
else
|
|
o.log.i('new windowlayout')
|
|
end
|
|
o.rules=parseRules(rules,o)
|
|
o:setScreenConfiguration(rules.screens)
|
|
return o
|
|
end
|
|
|
|
--- hs.window.layout:getRules() -> table
|
|
--- Method
|
|
--- Return a table with all the rules (and the screen configuration, if present) defined for this windowlayout
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * a table containing the rules of this windowlayout; you can pass this table (optionally
|
|
--- after performing valid manipulations) to `hs.window.layout.new()`
|
|
function layout:getRules()
|
|
local r={}
|
|
for _,rule in ipairs(self.rules) do
|
|
local nrule={}
|
|
nrule.windowfilter=rule.windowfilter:getFilters()
|
|
for _,command in ipairs(rule) do
|
|
nrule[#nrule+1]={
|
|
action=command.action,max=command.max,select=command.select,aspect=command.aspect,
|
|
rect=command.rect and geom.new(command.rect).table,
|
|
unitrect=command.unitrect and geom.new(command.unitrect).table,
|
|
screen=command.screen,--irule=irule,icmd=icmd,
|
|
}
|
|
end
|
|
r[#r+1]=nrule
|
|
end
|
|
r.screens=self.screens
|
|
return r
|
|
end
|
|
|
|
local function findScreen(s)
|
|
if not s then return elseif not screenCache[s] then screenCache[s]=screen.find(s) end
|
|
return screenCache[s]
|
|
end
|
|
|
|
-- applies pending actions on windows and apps (winbuf,appbuf)
|
|
local function performPendingActions()
|
|
-- show apps
|
|
if not next(winbuf) and not next(appbuf) then return end
|
|
log.vf('applying %d pending actions',tlen(winbuf))
|
|
for app,command in pairs(appbuf) do
|
|
if command.hide==false and app:isHidden() then
|
|
app:unhide()
|
|
command.log.f('rule %d.%d: %s unhidden',command.irule,command.icmd,app:name())
|
|
end
|
|
end
|
|
|
|
-- move/min/max/fs
|
|
for win,command in pairs(winbuf) do
|
|
local idx,appname,id=command.irule..'.'..command.icmd,win:application():name(),win:id()
|
|
local action=command.action
|
|
if action==MINIMIZE then
|
|
if not win:isMinimized() then
|
|
win:minimize()
|
|
command.log.f('rule %s: %s (%d) minimized',idx,appname,id)
|
|
end
|
|
winbuf[win]=nil
|
|
else
|
|
if win:isMinimized() then
|
|
win:unminimize()
|
|
command.log.f('rule %s: %s (%d) unminimized',idx,appname,id)
|
|
end
|
|
if action~=TILE and action~=FIT then
|
|
winbuf[win]=nil
|
|
local winscreen=win:screen()
|
|
local toscreen=findScreen(command.screen) or winscreen
|
|
if win:isFullScreen() and (action~=FULLSCREEN or toscreen~=winscreen) then
|
|
win:setFullScreen(false)
|
|
command.log.f('rule %s: %s (%d) unfullscreened',idx,appname,id)
|
|
end
|
|
if action==FULLSCREEN then
|
|
if toscreen~=winscreen then win:moveToScreen(toscreen) end
|
|
if not win:isFullScreen() then
|
|
win:setFullScreen(true)
|
|
command.log.f('rule %s: %s (%d) fullscreened to %s',idx,appname,id,toscreen:name())
|
|
end
|
|
elseif action==MAXIMIZE then
|
|
local frame=toscreen:frame()
|
|
if win:frame()~=frame then
|
|
win:setFrame(toscreen:frame())
|
|
command.log.f('rule %s: %s (%d) maximized to %s',idx,appname,id,toscreen:name())
|
|
end
|
|
elseif action==MOVE then
|
|
local frame=command.rect or toscreen:fromUnitRect(command.unitrect)
|
|
if win:frame()~=frame then
|
|
win:setFrame(frame)
|
|
command.log.f('rule %s: %s (%d) moved to %s',idx,appname,id,frame.string)
|
|
end
|
|
--elseif action==NOACTION then
|
|
--else --hs.assert(false,'(422) wrong action: '..action,command)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- tile remaining windows
|
|
local toTile={}
|
|
for win,command in pairs(winbuf) do
|
|
-- hs.assert(command.action==TILE or command.action==FIT,'(431) unexptected action: '..command.action,command)
|
|
local idx=command.irule..'.'..command.icmd
|
|
if not toTile[idx] then toTile[idx]=command
|
|
end
|
|
local e=toTile[idx]
|
|
e[command.nwindow]=win
|
|
winbuf[win]=nil
|
|
end
|
|
for idx,command in pairs(toTile) do
|
|
--FIXME assert for lack of holes in the list
|
|
-- hs.assert(#command>0,'(443)',command)
|
|
-- hs.assert(#command<=(command.max or 999),'(444)',command)
|
|
local toscreen=findScreen(command.screen) or command[1]:screen()
|
|
local frame=command.rect or toscreen:fromUnitRect(command.unitrect)
|
|
command.log.f('rule %s: %s %d windows into %s by %s',idx,command.action,#command,frame.string,command.select)
|
|
-- tileWindows(command,frame,command.aspect,command.select~=CLOSEST,command.action==FIT)
|
|
tileWindows(command,frame,command.aspect,false,command.action==FIT) -- always tile by position
|
|
end
|
|
|
|
-- hide apps
|
|
for app,command in pairs(appbuf) do
|
|
if command.hide==true and not app:isHidden() then
|
|
app:hide()
|
|
command.log.f('rule %d.%d: %s hidden',command.irule,command.icmd,app:name())
|
|
end
|
|
appbuf[app]=nil
|
|
end
|
|
-- hs.assert(not next(winbuf) and not next(appbuf),'(432)')
|
|
end
|
|
|
|
local function removeFromList(win,winlist)
|
|
local id=win:id() for i,w in ipairs(winlist) do if w:id()==id then tremove(winlist,i) return end end
|
|
end
|
|
local function findDestinationFrame(s,unitrect,candidateWindow)
|
|
local toscreen=findScreen(s) or (candidateWindow and candidateWindow:screen() or findScreen'0,0')
|
|
return unitrect and toscreen:fromUnitRect(unitrect) or toscreen:frame()
|
|
end
|
|
local function findClosestWindow(winlist,destFrame) --winlist must be sorted by focusedLast
|
|
-- hs.assert(#winlist>0,'no candidates for closest window')
|
|
local center,rd,rwin=destFrame.center,999999
|
|
for _,w in ipairs(winlist) do -- first, try the "smallest" of all windows already fully inside frame
|
|
local frame=w:frame()
|
|
-- TODO? if w:isVisible() then
|
|
if frame:inside(destFrame) then
|
|
local distance=frame.xy:distance(center)+frame.x2y2:distance(center)
|
|
if distance<rd then rd=distance rwin=w end
|
|
end
|
|
end
|
|
if rwin then return rwin end
|
|
for _,w in ipairs(winlist) do -- otherwise, just get the closest
|
|
local frame=w:frame()
|
|
local distance=frame:distance(center)
|
|
if distance<rd then rd=distance rwin=w end
|
|
end
|
|
-- hs.assert(rwin,'no closest window to '..destFrame.string,winlist)
|
|
return rwin
|
|
end
|
|
|
|
-- applies a layout rule onto the action buffers
|
|
local function applyRule(rule)
|
|
local irule=rule.irule
|
|
local l=rule.windowlayout.log
|
|
-- local rule=self.rules[irule]
|
|
local windows,windowsCreated=rule.windowfilter:getWindows(FOCUSEDLAST),rule.windowfilter:getWindows(CREATEDLAST)
|
|
log.vf('applying rule %d to %d windows',irule,#windows)
|
|
local icmd,nprocessed=1,0
|
|
--local ASSERT_ITER=1
|
|
-- local readdUnhiddenWindows={}
|
|
while icmd<=#rule and windows[1] do
|
|
-- ASSERT_ITER=ASSERT_ITER+1 hs.assert(ASSERT_ITER<100,'applyRule looping',rule.irule)
|
|
local command,win=rule[icmd]
|
|
local selector=command.select
|
|
if selector==CLOSEST then
|
|
local destFrame=command.rect or findDestinationFrame(command.screen,command.unitrect)
|
|
win=findClosestWindow(windows,destFrame)
|
|
l.vf('found closest window %d to %s',win:id(),destFrame.string)
|
|
elseif selector==FOCUSEDLAST then
|
|
win=windows[1]
|
|
elseif selector==CREATEDLAST then
|
|
win=windowsCreated[1]
|
|
elseif selector==CREATEDFIRST then
|
|
win=windowsCreated[#windowsCreated]
|
|
elseif selector==FRONTMOST then
|
|
if windows[1]:application() == application.frontmostApplication() then
|
|
win=windows[1]
|
|
nprocessed = 999
|
|
else
|
|
icmd=icmd+1 nprocessed=0
|
|
goto _next_
|
|
end
|
|
end
|
|
-- hs.assert(win,'no window to apply rule',rule)
|
|
|
|
removeFromList(win,windows) removeFromList(win,windowsCreated)
|
|
nprocessed=nprocessed+1
|
|
|
|
local buffered=winbuf[win]
|
|
if buffered and buffered.windowlayout~=rule.windowlayout then
|
|
l.ef('multiple active windowlayout instances for %s (%s)!',win:application():name(),win:id())
|
|
end
|
|
|
|
|
|
local action=command.action
|
|
if action==HIDE or action==UNHIDE then
|
|
local app=win:application()
|
|
if not appbuf[app] or appbuf[app].irule>irule then
|
|
appbuf[app]={hide=action==HIDE,log=l,irule=irule,icmd=icmd}
|
|
end
|
|
-- if action==UNHIDE then tinsert(readdUnhiddenWindows,win) end
|
|
-- action=NOACTION
|
|
else
|
|
if not buffered or buffered.irule>irule then
|
|
-- hs.assert(command.irule==irule and command.icmd==icmd,'(451) wrong indices: '..irule..'.'..icmd,command)
|
|
command.log=l
|
|
winbuf[win]={action=action,select=command.select,rect=command.rect,unitrect=command.unitrect,screen=command.screen,
|
|
max=command.max,aspect=command.aspect,log=command.log,irule=command.irule,icmd=command.icmd,nwindow=nprocessed,windowlayout=rule.windowlayout}
|
|
-- winbuf[win]=command winbuf[win].nwindow=nprocessed
|
|
l.df('pending action "%s" for %s (%d), %d total',action,win:application():name(),win:id(),tlen(winbuf))
|
|
end
|
|
end
|
|
if nprocessed>=(command.max or 999) then --done with this cmd
|
|
icmd=icmd+1 nprocessed=0
|
|
-- for i=#readdUnhiddenWindows,1,-1 do --was UNHIDE, now done
|
|
-- local w=readdUnhiddenWindows[i] tinsert(windows,w,1) tinsert(windowsCreated,w,1)
|
|
-- end
|
|
end
|
|
::_next_::
|
|
end
|
|
end
|
|
|
|
-- applies pending rules
|
|
local function applyPendingRules()
|
|
for rule in pairs(rulebuf) do applyRule(rule) end
|
|
rulebuf={} return performPendingActions()
|
|
end
|
|
|
|
|
|
--- hs.window.layout:apply()
|
|
--- Method
|
|
--- Applies the layout
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * the `hs.window.layout` object
|
|
---
|
|
--- Notes:
|
|
--- * if a screen configuration is defined for this windowfilter, and currently not satisfied, this method will do nothing
|
|
function layout:apply()
|
|
if screenInstances[self] and not self._screenConfigurationAllowed then
|
|
self.log.i('current screen configuration not allowed, ignoring apply') return self end
|
|
local batchID=windowfilter.startBatchOperation()
|
|
for _,rule in ipairs(self.rules) do applyRule(rule) end
|
|
self.log.i('Applying layout') performPendingActions()
|
|
windowfilter.stopBatchOperation(batchID)
|
|
return self
|
|
end
|
|
|
|
--- hs.window.layout.applyDelay
|
|
--- Variable
|
|
--- When "active mode" windowlayouts apply a rule, they will pause briefly for this amount of time in seconds, to allow windows
|
|
--- to "settle" in their new configuration without triggering other rules (or the same rule), which could result in a
|
|
--- cascade (or worse, a loop) or rules being applied. Defaults to 1; increase this if you experience unwanted repeated
|
|
--- triggering of rules due to sluggish performance.
|
|
layout.applyDelay=1
|
|
|
|
|
|
local DISTANT_FUTURE=315360000 -- 10 years (roughly)
|
|
|
|
--delay applying autolayouts while e.g. a window is being dragged with the mouse or moved via keyboard shortcuts
|
|
--or while switching focus with cmd-alt or cmd-`
|
|
local function checkMouseOrMods()
|
|
local mbut=eventtap.checkMouseButtons()
|
|
local mods=eventtap.checkKeyboardModifiers(true)._raw
|
|
return mods>0 or mbut.left or mbut.right or mbut.middle
|
|
end
|
|
local MODS_INTERVAL=0.2 -- recheck for (lack of) mouse buttons and mod keys after this interval
|
|
|
|
local modsTimer=timer.waitWhile(checkMouseOrMods,function(tmr)applyPendingRules()tmr:start():setNextTrigger(DISTANT_FUTURE)end,MODS_INTERVAL)
|
|
|
|
--- hs.window.layout:start() -> hs.window.layout object
|
|
--- Method
|
|
--- Puts a windowlayout instance in "active mode"
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * the `hs.window.layout` object
|
|
---
|
|
--- Notes:
|
|
--- * If a screen configuration is defined for this windowfilter, and currently not satisfied, this windowfilter will be put in "active mode" but will remain paused until the screen configuration requirements are met
|
|
--- * When in active mode, a windowlayout instance will constantly monitor the windowfilters for its rules, by subscribing to all the relevant events. As soon as any change is detected (e.g. when you drag a window, switch focus, open or close apps/windows, etc.) the relative rule will be automatically re-applied. In other words, the rules you defined will remain enforced all the time, instead of waiting for manual intervention via `hs.window.layout:apply()`.
|
|
function layout:start()
|
|
if activeInstances[self] then self.log.d('windowlayout instance already started') return self end
|
|
layout._hasActiveInstances=true -- used by hs.grid to pause all during modal operation
|
|
activeInstances[self]=true
|
|
self.log.i('starting windowlayout instance (active mode)')
|
|
return self:resume()
|
|
end
|
|
|
|
--- hs.window.layout:resume() -> hs.window.layout object
|
|
--- Method
|
|
--- Resumes an active windowlayout instance after it was paused
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * the `hs.window.layout` object
|
|
---
|
|
--- Notes:
|
|
--- * if a screen configuration is defined for this windowfilter, and currently not satisfied, this method will do nothing
|
|
function layout:resume()
|
|
if not activeInstances[self] then self.log.i('windowlayout instance not started, ignoring resume') return self end
|
|
if self.autolayout then self.log.d('windowlayout instance already running, ignoring resume') return self end
|
|
if screenInstances[self] and not self._screenConfigurationAllowed then
|
|
self.log.d('current screen configuration not allowed, ignoring resume') return self end
|
|
for _,rule in ipairs(self.rules) do
|
|
--timers and upvalues galore
|
|
local hasFocusedSelector--,hasPositionSelector
|
|
for _,cmd in ipairs(rule) do
|
|
if cmd.select==FOCUSEDLAST or cmd.select==FRONTMOST then hasFocusedSelector=true end
|
|
-- elseif cmd.select==CLOSEST then hasPositionSelector=true end
|
|
end
|
|
rule.callback=function()
|
|
rulebuf[rule]=true modsTimer:setNextTrigger(MODS_INTERVAL)
|
|
rule.windowfilter:unsubscribe(windowfilter.windowMoved,rule.callback)
|
|
rule.resubTimer:setNextTrigger(MODS_INTERVAL+window.animationDuration+layout.applyDelay)
|
|
end
|
|
if not rule.resubTimer then
|
|
rule.resubTimer=timer.new(DISTANT_FUTURE,
|
|
function()rule.windowfilter:subscribe(windowfilter.windowMoved,rule.callback)end):start()
|
|
end
|
|
rule.windowfilter:subscribe({windowfilter.windowVisible,windowfilter.windowNotVisible,windowfilter.windowMoved},rule.callback)
|
|
if hasFocusedSelector then rule.windowfilter:subscribe({windowfilter.windowFocused,windowfilter.windowUnfocused},rule.callback) end
|
|
end
|
|
self.log.i('windowlayout instance resumed')
|
|
self:apply()
|
|
self.autolayout=true
|
|
return self
|
|
end
|
|
|
|
--- hs.window.layout:pause() -> hs.window.layout object
|
|
--- Method
|
|
--- Pauses an active windowlayout instance; while paused no automatic window management will occur
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * the `hs.window.layout` object
|
|
function layout:pause()
|
|
if not activeInstances[self] then self.log.i('windowlayout instance not started, ignoring pause') return self end
|
|
if not self.autolayout then self.log.d('windowlayout instance already paused, ignoring') return self end
|
|
for _,rule in ipairs(self.rules) do
|
|
if rule.callback then rule.windowfilter:unsubscribe(rule.callback) rule.callback=nil end
|
|
if rule.resubTimer then rule.resubTimer:setNextTrigger(DISTANT_FUTURE) end
|
|
end
|
|
-- if self.timer then self.timer:stop() self.timer=nil end
|
|
self.autolayout=false
|
|
self.log.i('windowlayout instance paused')
|
|
return self
|
|
end
|
|
|
|
--- hs.window.layout:stop() -> hs.window.layout object
|
|
--- Method
|
|
--- Stops a windowlayout instance (i.e. not in "active mode" anymore)
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * the `hs.window.layout` object
|
|
function layout:stop()
|
|
if not activeInstances[self] then self.log.d('windowlayout instance already stopped') return self end
|
|
if self.autolayout then self:pause() end
|
|
activeInstances[self]=nil self.autolayout=nil
|
|
self.log.i('windowlayout instance stopped')
|
|
return self
|
|
end
|
|
|
|
local screenWatcher
|
|
|
|
function layout:delete()
|
|
self:stop()
|
|
for _,rule in ipairs(self.rules) do rule.windowfilter:delete() end self.rules={}
|
|
self.log.i('windowlayout instance deleted')
|
|
screenInstances[self]=nil
|
|
if not next(activeInstances) and not next(screenInstances) then
|
|
--global stop
|
|
if screenWatcher then screenWatcher:stop() screenWatcher=nil end
|
|
end
|
|
setmetatable(self,nil)
|
|
end
|
|
|
|
--- hs.window.layout.screensChangedDelay
|
|
--- Variable
|
|
--- The number of seconds to wait, after a screen configuration change has been detected, before
|
|
--- resuming any active windowlayouts that are allowed in the new configuration; defaults
|
|
--- to 10, to give sufficient time to OSX to do its own housekeeping
|
|
layout.screensChangedDelay = 10
|
|
|
|
local screensChangedTimer
|
|
local function screensChanged()
|
|
log.d'screens changed, pausing all active instances'
|
|
layout.pauseAllInstances()
|
|
screenCache={}
|
|
screensChangedTimer:setNextTrigger(layout.screensChangedDelay)
|
|
end
|
|
local function checkScreenInstances()
|
|
-- check screenInstances, set them active appropriately
|
|
local newActiveInstances={}
|
|
for wl in pairs(activeInstances) do newActiveInstances[wl]=true end
|
|
for wl in pairs(screenInstances) do
|
|
newActiveInstances[wl]=true
|
|
wl.log.v('checking screen configuration')
|
|
for hint, pos in pairs(wl.screens) do
|
|
local screens=tpack(screen.find(hint))
|
|
if pos==false then if #screens>0 then newActiveInstances[wl]=nil wl.log.df('screen %s is present, required absent',screens[1]:name()) break end
|
|
elseif pos==true then if #screens==0 then newActiveInstances[wl]=nil wl.log.df('screen %s is absent, required present',hint) break end
|
|
else
|
|
local sp,found=findScreen(pos),nil
|
|
if not sp then newActiveInstances[wl]=nil wl.log.df('screen at %s is absent, %s required',pos,hint) break end
|
|
local spid=sp:id()
|
|
for _,s in ipairs(screens) do if s:id()==spid then found=true break end end
|
|
if not found then newActiveInstances[wl]=nil wl.log.df('screen at %s is not %s',pos,hint) break end
|
|
end
|
|
end
|
|
end
|
|
for wl in pairs(screenInstances) do if not newActiveInstances[wl] and wl._screenConfigurationAllowed then
|
|
wl.log.i('current screen configuration not allowed')
|
|
wl._screenConfigurationAllowed=nil
|
|
if activeInstances[wl] then wl:pause() end
|
|
end end
|
|
for wl in pairs(newActiveInstances) do
|
|
wl.log.i('current screen configuration is allowed')
|
|
wl._screenConfigurationAllowed=true
|
|
if activeInstances[wl] then wl:resume() end
|
|
end
|
|
if next(screenInstances) then if not screenWatcher then screenWatcher=screen.watcher.new(screensChanged):start() end
|
|
elseif screenWatcher then screenWatcher:stop() screenWatcher=nil end
|
|
-- layout.resumeAllInstances()
|
|
end
|
|
local function processScreensChanged()
|
|
log.i'applying new screen configuration'
|
|
screensChangedTimer:setNextTrigger(DISTANT_FUTURE)
|
|
return checkScreenInstances()
|
|
end
|
|
|
|
screensChangedTimer=timer.new(DISTANT_FUTURE,processScreensChanged):start()
|
|
|
|
--- hs.window.layout:setScreenConfiguration(screens) -> hs.window.layout object
|
|
--- Method
|
|
--- Determines the screen configuration that permits applying this windowlayout
|
|
---
|
|
--- Parameters:
|
|
--- * screens - a map, where each *key* must be a valid "hint" for `hs.screen.find()`, and the corresponding
|
|
--- value can be:
|
|
--- * `true` - the screen must be currently present (attached and enabled)
|
|
--- * `false` - the screen must be currently absent
|
|
--- * an `hs.geometry` point (or constructor argument) - the screen must be present and in this specific
|
|
--- position in the current arragement (as per `hs.screen:position()`)
|
|
---
|
|
--- Returns:
|
|
--- * the `hs.window.layout` object
|
|
---
|
|
--- Notes:
|
|
--- * If `screens` is `nil`, any previous screen configuration is removed, and this windowlayout will be always allowed
|
|
--- * For "active" windowlayouts, call this method *before* calling `hs.window.layout:start()`
|
|
--- * By using `hs.geometry` size objects as hints you can define separate layouts for the same physical screen at different resolutions
|
|
--- * With this method you can define different windowlayouts for different screen configurations (as per System Preferences->Displays->Arrangement).
|
|
--- * For example, suppose you define two "graphics design work" windowlayouts, one for "desk with dual monitors" and one for "laptop only mode":
|
|
--- * "passive mode" use: you call `:apply()` on *both* on your chosen hotkey (via `hs.hotkey:bind()`), but only the appropriate layout for the current arrangement will be applied
|
|
--- * "active mode" use: you just call `:start()` on both windowlayouts; as you switch between workplaces (by attaching or detaching external screens) the correct layout "kicks in" automatically - this is in effect a convenience wrapper that calls `:pause()` on the no longer relevant layout, and `:resume()` on the appropriate one, at every screen configuration change
|
|
---
|
|
--- Examples:
|
|
--- ```lua
|
|
--- local laptop_layout,desk_layout=... -- define your layouts
|
|
--- -- just the laptop screen:
|
|
--- laptop_layout:setScreenConfiguration{['Color LCD']='0,0',dell=false,['3840x2160']=false}:start()
|
|
--- -- attached to a 4k primary + a Dell on the right:
|
|
--- desk_layout:setScreenConfiguration{['3840x2160']='0,0',['dell']='1,0',['Color LCD']='-1,0'}:start()
|
|
--- -- as above, but in clamshell mode (laptop lid closed):
|
|
--- clamshell_layout:setScreenConfiguration{['3840x2160']='0,0',['dell']='1,0',['Color LCD']=false}:start()
|
|
--- ```
|
|
function layout:setScreenConfiguration(screens)
|
|
if not screens then
|
|
screenInstances[self]=nil
|
|
self.screens=nil self.log.i'screen configuration removed'
|
|
else
|
|
if type(screens)~='table' then error('screens must be a map',2) end
|
|
local r={}
|
|
for hint,pos in pairs(screens) do
|
|
local s=validatescreen(hint,0) if not s then errorf('invalid screen hint: %s',hint,2) end
|
|
if type(pos)=='boolean' then
|
|
r[s]=pos self.log.f('screen configuration: %s %s',s,pos and 'present' or 'absent')
|
|
else
|
|
local ok,res=pcall(geom.new,pos)
|
|
if not ok or geom.type(res)~='point' then errorf('invalid screen position: %s',pos,2) end
|
|
r[s]=res self.log.f('screen configuration: %s at %s',s,res.string)
|
|
end
|
|
end
|
|
self.screens=r screenInstances[self]=true
|
|
end
|
|
checkScreenInstances()
|
|
return self
|
|
end
|
|
|
|
--- hs.window.layout.pauseAllInstances()
|
|
--- Function
|
|
--- Pauses all active windowlayout instances
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * None
|
|
function layout.pauseAllInstances() for wl in pairs(activeInstances) do wl:pause() end end
|
|
|
|
--- hs.window.layout.resumeAllInstances()
|
|
--- Function
|
|
--- Resumes all active windowlayout instances
|
|
---
|
|
--- Parameters:
|
|
--- * None
|
|
---
|
|
--- Returns:
|
|
--- * None
|
|
function layout.resumeAllInstances() for wl in pairs(activeInstances) do wl:resume() end end
|
|
|
|
--- hs.window.layout.applyLayout(rules)
|
|
--- Function
|
|
--- Applies a layout
|
|
---
|
|
--- Parameters:
|
|
--- * rules - see `hs.window.layout.new()`
|
|
---
|
|
--- Returns:
|
|
--- * None
|
|
---
|
|
--- Notes:
|
|
--- * this is a convenience wrapper for "passive mode" use that creates, applies, and deletes a windowlayout object;
|
|
--- do *not* use shared windowfilters in `rules`, as they'll be deleted; you can just use constructor argument maps instead
|
|
|
|
-- Note: the windowfilters are created on the fly, used and immediately deleted when done, must warn user against passing in his own windowfilters, or allow
|
|
-- *another* parameter (meh), or set up __mode='k' and __gc in wf's activeInstances; this to make stuff like
|
|
-- layout.apply{hs.application.frontmostApplication():title,...} straightforward
|
|
function layout.applyLayout(rules) layout.new(rules):apply():delete() end
|
|
|
|
|
|
--TODO...
|
|
--function layout.getLayout(layout,includeScreen)
|
|
--layout is optional
|
|
-- to detect tiling: no intersections, union area = sum of areas
|
|
--end
|
|
|
|
--TODO "restore" action
|
|
|
|
-- for 'restore': settings.set should be 1. screen definitions if provided or 2.screens geometry as per old windowlayouts
|
|
|
|
|
|
return layout
|