218 lines
11 KiB
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}
|