hammerspoon/extensions/window/window_tiling.lua

218 lines
11 KiB
Lua

--- === hs.window.tiling ===
---
--- **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)
---
--- Tile windows
---
--- The `tileWindows` function in this module is primarily meant for use by `hs.window.layout`; however you can call it manually
--- (e.g. for your custom layout engine).
-- BEWARE: horrible code (=accumulation of layers upon layers of ad-hoc fixes) ahead. This thing needs a proper rewrite.
-- very loosely based on http://www.win.tue.nl/~vanwijk/stm.pdf
local geom=require'hs.geometry'
local pairs,ipairs,next,type=pairs,ipairs,next,type
local max,min=math.max,math.min
local log=require'hs.logger'.new'wtiling'
--TODO rewrite this whole thing.
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 nextWindow(windowPool)
local ri,rw=999999
for win in pairs(windowPool) do if win.idx<ri then rw=win ri=win.idx end end
return rw
end
local function closestWindow(windowPool,point)
local rd,rw=999999
for win in pairs(windowPool) do
local d=win.posInUnion:distance(point)
if d<rd then rd=d rw=win end
end
return rw
--[[
-- return a sorted list of windows closest to a given point
local res={}
for win in pairs(windowPool) do res[#res+1]=win end
tsort(res,function(w1,w2) return w1.posInUnion:distance(point)<w2.posInUnion:distance(point) end)
return res[1]
--]]
end
local function switchedwh(r)return geom(r.x,r.y,r.h,r.w)end
local function getCentroid(switchedRectToFill,windowArea,i,n,fillVertically)
local h=switchedRectToFill.h
local r=geom.copy(switchedRectToFill):setw(windowArea*n/h)
if not fillVertically then r=switchedwh(r) r:setw(r.w/n):move(r.w*(i-1),0)
else r:seth(r.h/n):move(0,r.h*(i-1)) end
return r.center
end
--- hs.window.tiling.tileWindows(windows,rect[,desiredAspect[,processInOrder[,preserveRelativeArea[,animationDuration]]]])
--- Function
--- Tile (or fit) windows into a rect
---
--- Parameters:
--- * windows - a list of `hs.window` objects indicating the windows to tile or fit
--- * rect - an `hs.geometry` rect (or constructor argument), indicating the desired onscreen region that the windows will be tiled within
--- * desiredAspect - (optional) an `hs.geometry` size (or constructor argument) or a number, indicating the desired optimal aspect ratio (width/height) of the tiled windows; the tiling engine will decide how to subdivide the rect among windows by trying to maintain every window's aspect ratio as close as possible to this; if omitted, defaults to 1 (i.e. try to keep the windows as close to square as possible)
--- * processInOrder - (optional) if `true`, windows will be placed left-to-right and top-to-bottom following the list order in `windows`; if `false` or omitted, the tiling engine will try to maintain the spatial distribution of windows, i.e. (roughly speaking) pick the closest window for each destination "tile"; note that in some cases this isn't possible and the windows might get "reshuffled" around in unexpected ways
--- * preserveRelativeArea - (optional) if `true`, preserve the relative area among windows; that is, if window A is currently twice as large as window B, the same will be true after both windows have been processed and placed into the rect; if `false` or omitted, all windows will have the same area (= area of the rect / number of windows) after processing
--- * animationDuration - (optional) the number of seconds to animate the move/resize operations of the windows; if omitted, defaults to the value of `hs.window.animationDuration`
---
--- Returns:
--- * None
---
--- Notes:
--- * To ensure all windows are placed in a row (side by side), use a very small aspect ratio (for "tall and narrow" windows) like 0.01;
--- similarly, to have all windows in a column, use a very large aspect ratio (for "short and wide") like 100
--- * Hidden and minimized windows will be processed as well: the rect will have "gaps" where the invisible windows
--- would lie, that will get filled as the windows get unhidden/unminimized
-- TODO enforce minimum width? (optional arg)
local function tileWindows(windows,rect,desiredAspect,processInOrder,preserveRelativeArea,animationDuration)
if type(windows)~='table' then error('windows must be a list of hs.window objects',2)end
if #windows==0 then return end
if getmetatable(windows[1])~=hs.getObjectMetatable'hs.window' then error('windows must be a list of hs.window objects',2)end
local ok,res=pcall(geom.new,rect)
if not ok or geom.type(res)~='rect' or res.area==0 then error('rect must be a valid hs.geometry rect: '..tostring(res),2)
else rect=res end
if not desiredAspect then desiredAspect=1
elseif type(desiredAspect)~='number' then desiredAspect=geom.new(desiredAspect).aspect end
log.df('tiling %d windows into %s, aspect=%.2f%s',#windows,rect.string,desiredAspect,preserveRelativeArea and ', preserve relative area' or '')
local wins={}
local totalArea,avgWindowArea=0,1/#windows
local unionRect=windows[1]:frame()
for i,w in ipairs(windows) do
local f=w:frame()
if preserveRelativeArea then totalArea=totalArea+f.area end
unionRect=unionRect:union(f)
wins[{frame=f,area=f.area,window=w,idx=i}]=true
end
log.vf('window union rect: %s',unionRect.string)
for win in pairs(wins) do
win.area=preserveRelativeArea and win.area/totalArea or avgWindowArea
win.posInUnion=win.frame:toUnitRect(unionRect).center
log.vf('window #%d at %.2f,%.2f, area %.2f',win.idx,win.posInUnion.x,win.posInUnion.y,win.area)
end
local rectToFill=geom(0,0,1,1)
local doneWindows={}
repeat
local rowWindows,rowAspectRatio,rowSwitchedRectToFill={},{},{}
for dir=1,2 do
local fillVertically=dir==1
local desiredRowAspect=fillVertically and desiredAspect or 1/desiredAspect
local adjustAspect=fillVertically and rect.aspect or 1/rect.aspect
log.vf('finding optimal %s to fill %s',fillVertically and 'column' or 'row',rectToFill.string)
local switchedRectToFill=fillVertically and rectToFill or switchedwh(rectToFill)
rowSwitchedRectToFill[dir]=switchedRectToFill
local rowDone,bestWidth
local testn=0
rowWindows[dir]={}
repeat
--TODO seriously: *at least* refactor this loop out to "makeRow" or something
-- add another window to the row until optimality drops
local windowsRemaining={}
for win in pairs(wins) do windowsRemaining[win]=true end
testn=testn+1
local testArea,testWindows=0,{}
for i=1,testn do
local win
if processInOrder then win=nextWindow(windowsRemaining)
log.vf('try %d/%d, pick #%d (area %.2f)',i,testn,win.idx,win.area)
else
local centroid=getCentroid(switchedRectToFill,avgWindowArea,i,testn,fillVertically)
win=closestWindow(windowsRemaining,centroid)
log.vf('try %d/%d, pick #%d (area %.2f, centroid %.2f,%.2f, distance %.2f)',i,testn,win.idx,win.area,centroid.x,centroid.y,win.posInUnion:distance(centroid))
end
testWindows[i]=win
testArea=testArea+win.area
windowsRemaining[win]=nil
end
local testFrame={x=switchedRectToFill.x,y=switchedRectToFill.y,w=testArea/switchedRectToFill.h}
local testAspectRatio=1
-- find optimality
for _,win in ipairs(testWindows) do
testFrame.h=win.area/testFrame.w
local winAspectRatio=(testFrame.w/testFrame.h) * adjustAspect / desiredRowAspect
--local __=geom(testFrame)
if winAspectRatio>1 then winAspectRatio=1/winAspectRatio end
testAspectRatio=min(testAspectRatio,winAspectRatio) --accumulate worst ratio aspect:desiredAspect (optimality)
win.testFrame=geom.copy(testFrame)
testFrame.y=testFrame.y+testFrame.h --move frame down for next win in column
end
rowAspectRatio[dir]=max(rowAspectRatio[dir] or testAspectRatio,testAspectRatio) -- best so far
if not next(windowsRemaining) and testAspectRatio<rowAspectRatio[dir] then
--if this is the last window and it was refused, consider what happens when it goes into its own row
local lastRect=geom.copy(switchedRectToFill)
lastRect.x=lastRect.x+bestWidth lastRect.x2=switchedRectToFill.x2
local lastAspect=fillVertically and lastRect.aspect or 1/lastRect.aspect
local lastAspectRatio=lastAspect*adjustAspect/desiredRowAspect
if lastAspectRatio>1 then lastAspectRatio=1/lastAspectRatio end
rowAspectRatio[dir]=min(lastAspectRatio,rowAspectRatio[dir])
end
log.vf('optimality for %s of %d: %.0f%%',fillVertically and 'column' or 'row',testn,testAspectRatio*100)
if testAspectRatio<rowAspectRatio[dir] then --whops, things got worse, undo
rowDone=true
else --yay, improvement, save it
rowWindows[dir]={}
for _,win in ipairs(testWindows) do win[dir]=win.testFrame rowWindows[dir][win]=true end
bestWidth=testFrame.w
rowAspectRatio[dir]=testAspectRatio
end
if not next(windowsRemaining) then rowDone=true end
until rowDone
log.vf('%s done, %d windows, optimality %.0f%%',fillVertically and 'column' or 'row',tlen(rowWindows[dir]),rowAspectRatio[dir]*100)
end
local bestDir=rowAspectRatio[1]>rowAspectRatio[2] and 1 or 2
local wasVertical=bestDir==1
local bestWindows=rowWindows[bestDir]
log.vf('picking %s of %d windows, optimality %.0f%%',wasVertical and 'column' or 'row',tlen(bestWindows),rowAspectRatio[bestDir]*100)
local tempRect=rowSwitchedRectToFill[bestDir]
--save the optimal row: get the row frame
local tempWindowFrame=next(bestWindows)[bestDir]
local switchedRowFrame=geom(tempRect.x,tempRect.y,tempWindowFrame.w,tempRect.h)
for win in pairs(bestWindows) do
if not wasVertical then
--switch around the frame
win.bestFrame=geom(rectToFill.x+win[bestDir].y-rectToFill.y,rectToFill.y,win[bestDir].h,win[bestDir].w)
else win.bestFrame=win[bestDir] end
log.vf('window #%d -> %s',win.idx,win.bestFrame.string)
doneWindows[#doneWindows+1]=win
wins[win]=nil
end
--local rowFrame=geom(rectToFill.y,rectToFill.y,wasVertical and tempWindowFrame.w or rectToFill.w,wasVertical and rectToFill.h or tempWindowFrame.h)
--get the remaining rect to fill
local temp=rectToFill.x2y2
rectToFill:move(wasVertical and switchedRowFrame.w or 0,wasVertical and 0 or switchedRowFrame.w)
rectToFill.x2y2=temp
until not next(wins)
--finally, apply
-- hs.assert(#doneWindows==#windows,'tileWindows: some windows were not processed',windows)
for _,win in ipairs(doneWindows) do
local w,frame=win.window,win.bestFrame:fromUnitRect(rect):floor()
if w:frame()~=frame then
log.f('%s (%d) -> %s',w:application():name(),w:id(),frame.string)
w:setFrame(frame,animationDuration)
end
end
end
return {tileWindows=tileWindows,setLogLevel=log.setLogLevel,getLogLevel=log.getLogLevel}