hammerspoon/extensions/grid/grid.lua

1188 lines
46 KiB
Lua

--- === hs.grid ===
---
--- Move/resize windows within a grid
---
--- The grid partitions your screens for the purposes of window management. The default layout of the grid is 3 columns by 3 rows.
--- You can specify different grid layouts for different screens and/or screen resolutions.
---
--- Windows that are aligned with the grid have their location and size described as a `cell`. Each cell is an `hs.geometry` rect with these fields:
--- * x - The column of the left edge of the window
--- * y - The row of the top edge of the window
--- * w - The number of columns the window occupies
--- * h - The number of rows the window occupies
---
--- For a grid of 3x3:
--- * a cell `'0,0 1x1'` will be in the upper-left corner
--- * a cell `'2,0 1x1'` will be in the upper-right corner
--- * and so on...
---
--- Additionally, a modal keyboard driven interface for interactive resizing is provided via `hs.grid.show()`;
--- The grid will be overlaid on the focused or frontmost window's screen with keyboard hints.
--- To resize/move the window, you can select the corner cells of the desired position.
--- For a move-only, you can select a cell and confirm with 'return'. The selected cell will become the new upper-left of the window.
--- You can also use the arrow keys to move the window onto adjacent screens, and the tab/shift-tab keys to cycle to the next/previous window.
--- Once you selected a cell, you can use the arrow keys to navigate through the grid. In this case, the grid will highlight the selected cells.
--- After highlighting enough cells, press enter to move/resize the window to the highlighted area.
local window = require "hs.window"
local screen = require 'hs.screen'
local drawing = require'hs.drawing'
local geom = require'hs.geometry'
local timer = require'hs.timer'
local newmodal = require'hs.hotkey'.modal.new
local log = require'hs.logger'.new('grid')
local ipairs,pairs,min,max,floor,fmod = ipairs,pairs,math.min,math.max,math.floor,math.fmod
local sformat,ssub,ulen,type,tostring = string.format,string.sub,utf8.len,type,tostring
local tinsert,tpack=table.insert,table.pack
local setmetatable,rawget,rawset=setmetatable,rawget,rawset
local gridSizes = {[true]=geom'3x3'} -- user-defined grid sizes for each screen or geometry, default ([true]) is 3x3
local gridFrames= {} -- user-defined grid frames; always defaults to the screen:frame()
local margins = geom'5x5'
local grid = {setLogLevel=log.setLogLevel,getLogLevel=log.getLogLevel} -- module
--- hs.grid.setGrid(grid,screen,frame) -> hs.grid
--- Function
--- Sets the grid size for a given screen or screen resolution
---
--- Parameters:
--- * grid - an `hs.geometry` size, or argument to construct one, indicating the number of columns and rows for the grid
--- * screen - an `hs.screen` object, or a valid argument to `hs.screen.find()`, indicating the screen(s) to apply the grid to;
--- if omitted or nil, sets the default grid, which is used when no specific grid is found for any given screen/resolution
--- * frame - an `hs.geometry` rect object indicating the frame that the grid will occupy for the given screen;
--- if omitted or nil, the screen's `:frame()` will be used; use this argument if you want e.g. to leave
--- a strip of the desktop unoccluded when using GeekTool or similar. The `screen` argument *must* be non-nil when setting a
--- custom grid frame.
---
--- Returns:
--- * the `hs.grid` module for method chaining
---
--- Usage:
--- hs.grid.setGrid('5x3','Color LCD') -- sets the grid to 5x3 for any screen named "Color LCD"
--- hs.grid.setGrid('8x5','1920x1080') -- sets the grid to 8x5 for all screens with a 1920x1080 resolution
--- hs.grid.setGrid'4x4' -- sets the default grid to 4x4
local deleteUI
local function getScreenParam(scr)
if scr==nil then return true end
if getmetatable(scr)==hs.getObjectMetatable'hs.screen' then scr=scr:id() end
if type(scr)=='string' or type(scr)=='table' then
local ok,res=pcall(geom.new,scr)
if ok then scr=res.string end
end
if type(scr)~='string' and type(scr)~='number' then error('invalid screen or geometry',3) end
return scr
end
function grid.setGrid(gr,scr,frame)
gr=geom.new(gr)
if geom.type(gr)~='size' then error('invalid grid',2) end
scr=getScreenParam(scr)
gr.w=min(gr.w,100) gr.h=min(gr.h,100) -- cap grid to 100x100, just in case
gridSizes[scr]=gr
if frame~=nil then
frame=geom.new(frame)
if geom.type(frame)~='rect' then error('invalid frame',2) end
if scr==true then error('can only set the grid frame for a specific screen',2) end
gridFrames[scr]=frame
end
if scr==true then log.f('default grid set to %s',gr.string)
else log.f('grid for %s set to %s',scr,gr.string) end
deleteUI()
return grid
end
--- hs.grid.setMargins(margins) -> hs.grid
--- Function
--- Sets the margins between windows
---
--- Parameters:
--- * margins - an `hs.geometry` point or size, or argument to construct one, indicating the desired margins between windows in screen points
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.setMargins(mar)
mar=geom.new(mar)
if geom.type(mar)=='point' then mar=geom.size(mar.x,mar.y) end
if geom.type(mar)~='size' then error('invalid margins',2)end
margins=mar
log.f('window margins set to %s',margins.string)
return grid
end
--- hs.grid.getGrid(screen) -> hs.geometry size
--- Function
--- Gets the defined grid size for a given screen or screen resolution
---
--- Parameters:
--- * screen - an `hs.screen` object, or a valid argument to `hs.screen.find()`, indicating the screen to get the grid of;
--- if omitted or nil, gets the default grid, which is used when no specific grid is found for any given screen/resolution
---
--- Returns:
--- * an `hs.geometry` size object indicating the number of columns and rows in the grid
---
--- Notes:
--- * if a grid was not set for the specified screen or geometry, the default grid will be returned
---
--- Usage:
--- local mygrid = hs.grid.getGrid('1920x1080') -- gets the defined grid for all screens with a 1920x1080 resolution
--- local defgrid=hs.grid.getGrid() defgrid.w=defgrid.w+2 -- increases the number of columns in the default grid by 2
-- interestingly, that last example above can be used to defeat the 100x100 cap
local function getGrid(screenObject)
if not screenObject then return gridSizes[true] end
local id=screenObject:id()
for k,gridsize in pairs(gridSizes) do
if k~=true then
local screens=tpack(screen.find(k))
for _,s in ipairs(screens) do if s:id()==id then return gridsize end end
end
end
return gridSizes[true]
end
function grid.getGrid(scr)
scr=getScreenParam(scr)
if gridSizes[scr] then return gridSizes[scr] end
return getGrid(screen.find(scr))
end
--- hs.grid.getGridFrame(screen) -> hs.geometry rect
--- Function
--- Gets the defined grid frame for a given screen or screen resolution.
---
--- Parameters:
--- * screen - an `hs.screen` object, or a valid argument to `hs.screen.find()`, indicating the screen to get the grid frame of
---
--- Returns:
--- * an `hs.geometry` rect object indicating the frame used by the grid for the given screen; if no custom frame
--- was given via `hs.grid.setGrid()`, returns the screen's frame
local function getGridFrame(screenObject)
if not screenObject then error('cannot find screen',2) end
local id=screenObject:id()
local screenFrame=screenObject:fullFrame()
for k,gridframe in pairs(gridFrames) do
local screens=tpack(screen.find(k))
for _,s in ipairs(screens) do if s:id()==id then
local f=screenFrame:intersect(gridframe)
if f.area==0 then
error(sformat('invalid grid frame %s defined for "%s" (screen %s has frame %s)',gridframe.string,tostring(k),screenObject:name(),screenFrame.string),2)
end
return f
end end
end
return screenObject:frame()
end
function grid.getGridFrame(scr)
scr=getScreenParam(scr)
if scr==true then error('must specify a screen',2) end
if gridFrames[scr] then return gridFrames[scr] end
return getGridFrame(screen.find(scr))
end
--- hs.grid.show([exitedCallback][, multipleWindows])
--- Function
--- Shows the grid and starts the modal interactive resizing process for the focused or frontmost window.
---
--- Parameters:
--- * exitedCallback - (optional) a function that will be called after the user dismisses the modal interface
--- * multipleWindows - (optional) if `true`, the resizing grid won't automatically go away after selecting the desired cells for the frontmost window; instead, it'll switch to the next window
---
--- Returns:
--- * None
---
--- Notes:
--- * In most cases this function should be invoked via `hs.hotkey.bind` with some keyboard shortcut.
--- * In the modal interface, press the arrow keys to jump to adjacent screens; spacebar to maximize/unmaximize; esc to quit without any effect
--- * Pressing `tab` or `shift-tab` in the modal interface will cycle to the next or previous window; if `multipleWindows`
--- is false or omitted, the first press will just enable the multiple windows behaviour
--- * The keyboard hints assume a QWERTY layout; if you use a different layout, change `hs.grid.HINTS` accordingly
--- * If grid dimensions are greater than 10x10 then you may have to change `hs.grid.HINTS` depending on your
--- requirements. See note in `HINTS`.
--- hs.grid.hide()
--- Function
--- Hides the grid, if visible, and exits the modal resizing 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`.
--- * If an exit callback was provided when invoking the modal interface, calling `.hide()` will call it
--- hs.grid.toggleShow([exitedCallback][, multipleWindows])
--- Function
--- Toggles the grid and modal resizing mode - see `hs.grid.show()` and `hs.grid.hide()`
---
--- Parameters:
--- * exitedCallback - (optional) a function that will be called after the user dismisses the modal interface
--- * multipleWindows - (optional) if `true`, the resizing grid won't automatically go away after selecting the desired cells for the frontmost window; instead, it'll switch to the next window
---
--- Returns:
--- * None
local function getCellSize(theScreen)
local g = getGrid(theScreen)
local screenframe=getGridFrame(theScreen)
return geom.size(screenframe.w / g.w, screenframe.h / g.h)
end
local function round(num, idp)
local mult = 10^(idp or 0)
return floor(num * mult + 0.5) / mult
end
--- hs.grid.get(win) -> cell
--- Function
--- Gets the cell describing a window
---
--- Parameters:
--- * an `hs.window` object to get the cell of
---
--- Returns:
--- * a cell object (i.e. an `hs.geometry` rect), or nil if an error occurred
function grid.get(win)
local winframe = win:frame()
local winscreen = win:screen()
if not winscreen then log.e('Cannot get the window\'s screen') return end
local screenframe = getGridFrame(winscreen)
local cellsize = getCellSize(winscreen)
return geom{
x = round((winframe.x - screenframe.x) / cellsize.w),
y = round((winframe.y - screenframe.y) / cellsize.h),
w = max(1, round(winframe.w / cellsize.w)),
h = max(1, round(winframe.h / cellsize.h)),
}
end
--- hs.grid.getCell(cell, screen) -> hs.geometry
--- Function
--- Gets the `hs.geometry` rect for a cell on a particular screen
---
--- Parameters:
--- * cell - a cell object, i.e. an `hs.geometry` rect or argument to construct one
--- * screen - an `hs.screen` object or argument to `hs.screen.find()` where the cell is located
---
--- Returns:
--- * the `hs.geometry` rect for a cell on a particular screen or nil if the screen isn't found
function grid.getCell(cell, scr)
scr=screen.find(scr)
if not scr then log.e('screen cannot be nil') return end
cell=geom.new(cell)
local screenrect = scr:frame()
local screengrid = getGrid(scr)
-- sanitize, because why not
cell.x=max(0,min(cell.x,screengrid.w-1)) cell.y=max(0,min(cell.y,screengrid.h-1))
cell.w=max(1,min(cell.w,screengrid.w-cell.x)) cell.h=max(1,min(cell.h,screengrid.h-cell.y))
local cellw, cellh = screenrect.w/screengrid.w, screenrect.h/screengrid.h
local newframe = {
x = (cell.x * cellw) + screenrect.x,
y = (cell.y * cellh) + screenrect.y,
w = cell.w * cellw,
h = cell.h * cellh,
}
return newframe
end
--- hs.grid.set(win, cell, screen) -> hs.grid
--- Function
--- Sets the cell for a window on a particular screen
---
--- Parameters:
--- * win - an `hs.window` object representing the window to operate on
--- * cell - a cell object, i.e. an `hs.geometry` rect or argument to construct one, to apply to the window
--- * screen - (optional) an `hs.screen` object or argument to `hs.screen.find()` representing the screen to place the window on; if omitted
--- the window's current screen will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.set(win, cell, scr)
if not win then error('win cannot be nil',2) end
scr=screen.find(scr)
if not scr then scr=win:screen() end
if not scr then log.e('Cannot get the window\'s screen') return grid end
cell=geom.new(cell)
local screenrect = getGridFrame(scr)
local screengrid = getGrid(scr)
-- sanitize, because why not
cell.x=max(0,min(cell.x,screengrid.w-1)) cell.y=max(0,min(cell.y,screengrid.h-1))
cell.w=max(1,min(cell.w,screengrid.w-cell.x)) cell.h=max(1,min(cell.h,screengrid.h-cell.y))
local cellw, cellh = screenrect.w/screengrid.w, screenrect.h/screengrid.h
local newframe = {
x = (cell.x * cellw) + screenrect.x + margins.w,
y = (cell.y * cellh) + screenrect.y + margins.h,
w = cell.w * cellw - (margins.w * 2),
h = cell.h * cellh - (margins.h * 2),
}
-- ensure windows are not spaced by a double margin
if cell.h < screengrid.h and cell.h % 1 == 0 then
if cell.y ~= 0 then
newframe.h = newframe.h + margins.h / 2
newframe.y = newframe.y - margins.h / 2
end
if cell.y + cell.h ~= screengrid.h then
newframe.h = newframe.h + margins.h / 2
end
end
if cell.w < screengrid.w and cell.w % 1 == 0 then
if cell.x ~= 0 then
newframe.w = newframe.w + margins.w / 2
newframe.x = newframe.x - margins.w / 2
end
if cell.x + cell.w ~= screengrid.w then
newframe.w = newframe.w + margins.w / 2
end
end
win:setFrameInScreenBounds(newframe) --TODO check this (against screen bottom stickiness)
return grid
end
--- hs.grid.snap(win) -> hs.grid
--- Function
--- Snaps a window into alignment with the nearest grid lines
---
--- Parameters:
--- * win - an `hs.window` object to snap
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.snap(win)
if win:isStandard() then
local cell = grid.get(win)
if cell then grid.set(win, cell)
else log.e('Cannot get the window\'s cell') end
else log.e('Cannot snap nonstandard window') end
return grid
end
--- hs.grid.adjustWindow(fn, window) -> hs.grid
--- Function
--- Calls a user specified function to adjust a window's cell
---
--- Parameters:
--- * fn - a function that accepts a cell object as its only argument. The function should modify it as needed and return nothing
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.adjustWindow(fn,win)
if not win then win = window.frontmostWindow() end
if not win then log.w('Cannot get frontmost window') return grid end
local f = grid.get(win)
if not f then log.e('Cannot get window cell') return grid end
fn(f)
return grid.set(win, f)
end
grid.adjustFocusedWindow=grid.adjustWindow
local function checkWindow(win)
if not win then win = window.frontmostWindow() end
if not win then log.w('Cannot get frontmost window') return end
if not win:screen() then log.w('Cannot get the window\'s screen') return end
return win
end
--- hs.grid.maximizeWindow(window) -> hs.grid
--- Function
--- Moves and resizes a window to fill the entire grid
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.maximizeWindow(win)
win=checkWindow(win) if not win then return grid end
local winscreen = win:screen()
local screengrid = getGrid(winscreen)
return grid.set(win, {0,0,screengrid.w,screengrid.h}, winscreen)
end
-- deprecate these two, :next() and :previous() screens are useless anyway due to random order
function grid.pushWindowNextScreen(win)
win=checkWindow(win) if not win then return grid end
local winscreen=win:screen()
win:moveToScreen(winscreen:next())
return grid.snap(win)
end
function grid.pushWindowPrevScreen(win)
win=checkWindow(win) if not win then return grid end
local winscreen=win:screen()
win:moveToScreen(winscreen:previous())
return grid.snap(win)
end
--- hs.grid.pushWindowLeft(window) -> hs.grid
--- Function
--- Moves a window one grid cell to the left, or onto the adjacent screen's grid when necessary
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.pushWindowLeft(win)
win=checkWindow(win) if not win then return grid end
local winscreen = win:screen()
local cell = grid.get(win)
if cell.x<=0 then
-- go to left screen
local frame=win:frame()
local newscreen=winscreen:toWest(frame)
if not newscreen then return grid end
frame.x = frame.x-frame.w
win:setFrameInScreenBounds(frame)
return grid.snap(win)
else return grid.adjustWindow(function(f)f.x=f.x-1 end, win) end
end
--- hs.grid.pushWindowRight(window) -> hs.grid
--- Function
--- Moves a window one cell to the right, or onto the adjacent screen's grid when necessary
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.pushWindowRight(win)
win=checkWindow(win) if not win then return grid end
local winscreen = win:screen()
local screengrid = getGrid(winscreen)
local cell = grid.get(win)
if cell.x+cell.w>=screengrid.w then
-- go to right screen
local frame=win:frame()
local newscreen=winscreen:toEast(frame)
if not newscreen then return grid end
frame.x = frame.x+frame.w
win:setFrameInScreenBounds(frame)
return grid.snap(win)
else return grid.adjustWindow(function(f)f.x=f.x+1 end, win) end
end
--- hs.grid.resizeWindowWider(window) -> hs.grid
--- Function
--- Resizes a window to be one cell wider
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
---
--- Notes:
--- * if the window hits the right edge of the screen and is asked to become wider, its left edge will shift further left
function grid.resizeWindowWider(win)
win=checkWindow(win) if not win then return grid end
local screengrid = getGrid(win:screen())
return grid.adjustWindow(function(f)
if f.w + f.x >= screengrid.w and f.x > 0 then
f.x = f.x - 1
end
f.w = min(f.w + 1, screengrid.w - f.x)
end, win)
end
--- hs.grid.resizeWindowThinner(window) -> hs.grid
--- Function
--- Resizes a window to be one cell thinner
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.resizeWindowThinner(win)
return grid.adjustWindow(function(f) f.w = max(f.w - 1, 1) end, win)
end
--- hs.grid.pushWindowDown(window) -> hs.grid
--- Function
--- Moves a window one grid cell down the screen, or onto the adjacent screen's grid when necessary
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.pushWindowDown(win)
win=checkWindow(win) if not win then return grid end
local winscreen = win:screen()
local screengrid = getGrid(winscreen)
local cell = grid.get(win)
if cell.y+cell.h>=screengrid.h then
-- go to screen below
local frame=win:frame()
local newscreen=winscreen:toSouth(frame)
if not newscreen then return grid end
frame.y = frame.y+frame.h
win:setFrameInScreenBounds(frame)
return grid.snap(win)
else return grid.adjustWindow(function(f)f.y=f.y+1 end, win) end
end
--- hs.grid.pushWindowUp(window) -> hs.grid
--- Function
--- Moves a window one grid cell up the screen, or onto the adjacent screen's grid when necessary
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.pushWindowUp(win)
win=checkWindow(win) if not win then return grid end
local winscreen = win:screen()
local cell = grid.get(win)
if cell.y<=0 then
-- go to screen above
local frame=win:frame()
local newscreen=winscreen:toNorth(frame)
if not newscreen then return grid end
frame.y = frame.y-frame.h
win:setFrameInScreenBounds(frame)
return grid.snap(win)
else return grid.adjustWindow(function(f)f.y=f.y-1 end, win) end
end
--- hs.grid.resizeWindowShorter(window) -> hs.grid
--- Function
--- Resizes a window so its bottom edge moves one grid cell higher
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
function grid.resizeWindowShorter(win)
return grid.adjustWindow(function(f) f.y = f.y - 0; f.h = max(f.h - 1, 1) end, win)
end
--- hs.grid.resizeWindowTaller(window) -> hs.grid
--- Function
--- Resizes a window so its bottom edge moves one grid cell lower
---
--- Parameters:
--- * window - an `hs.window` object to act on; if omitted, the focused or frontmost window will be used
---
--- Returns:
--- * the `hs.grid` module for method chaining
---
--- Notes:
--- * if the window hits the bottom edge of the screen and is asked to become taller, its top edge will shift further up
function grid.resizeWindowTaller(win)
win=checkWindow(win) if not win then return grid end
local screengrid = getGrid(win:screen())
return grid.adjustWindow(function(f)
if f.y + f.h >= screengrid.h and f.y > 0 then
f.y = f.y -1
end
f.h = min(f.h + 1, screengrid.h - f.y)
end, win)
end
--- hs.grid.HINTS
--- Variable
--- A bidimensional array (table of tables of strings) holding the keyboard hints (as per `hs.keycodes.map`) to be used for the interactive resizing interface.
--- Change this if you don't use a QWERTY layout; you need to provide 5 valid rows of hints (even if you're not going to use all 5 rows)
---
--- Default `HINTS` is an array to 5 rows and 10 columns.
---
--- Notes:
--- * `hs.inspect(hs.grid.HINTS)` from the console will show you how the table is built
--- * `hs.grid.show()`
--- When displaying interactive grid, if gird dimensions (`hs.grid.setGrid()`) are greater than `HINTS` dimensions,
--- then Hammerspoon merges few cells such that interactive grid dimensions do not exceed `HINTS` dimensions.
--- This is done to make sure interactive grid cells do not run out of hints. The interactive grid ends up with
--- cells of varying height and width.
--- The actual grid is not affected. If you use API methods like `hs.grid.pushWindowDown()`, you will not face this
--- issue at all.
--- If you have a grid of higher dimensions and require an interactive gird that accurately models underlying grid
--- then set `HINTS` variable to a table that has same dimensions as your grid.
--- Following is an example of grid that has 16 columns
---
--- ```
--- hs.grid.setGrid('16x4')
--- hs.grid.HINTS={
--- {'f1', 'f2' , 'f3' , 'f4' , 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', 'f13', 'f14', 'f15', 'f16'},
--- {'1' , 'f11', 'f15', 'f19', 'f3', '=' , ']' , '2' , '3' , '4' , '5' , '6' , '7' , '8' , '9' , '0' },
--- {'Q' , 'f12', 'f16', 'f20', 'f4', '-' , '[' , 'W' , 'E' , 'R' , 'T' , 'Y' , 'U' , 'I' , 'O' , 'P' },
--- {'A' , 'f13', 'f17', 'f1' , 'f5', 'f7', '\\', 'S' , 'D' , 'F' , 'G' , 'H' , 'J' , 'K' , 'L' , ',' },
--- {'X' , 'f14', 'f18', 'f2' , 'f6', 'f8', ';' , '/' , '.' , 'Z' , 'X' , 'C' , 'V' , 'B' , 'N' , 'M' }
--- }
--- ```
---
-- modal grid stuff below
grid.HINTS={{'f1','f2','f3','f4','f5','f6','f7','f8','f9','f10'},
{'1','2','3','4','5','6','7','8','9','0'},
{'Q','W','E','R','T','Y','U','I','O','P'},
{'A','S','D','F','G','H','J','K','L',';'},
{'Z','X','C','V','B','N','M',',','.','/'}
}
local _HINTROWS,_HINTS = {{4},{3,4},{3,4,5},{2,3,4,5},{1,2,3,4,5},{1,2,3,9,4,5},{1,2,8,3,9,4,5},{1,2,8,3,9,4,10,5},{1,7,2,8,3,9,4,10,5},{1,6,2,7,3,8,9,4,10,5}}
-- 10x10 grid should be enough for anybody
local function getColor(t)
if t.red 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.grid.ui
--- Variable
--- Allows customization of the modal resizing grid user interface
---
--- This table contains variables that you can change to customize the look of the modal resizing grid.
--- 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
---
--- The following variables must be color values:
--- * `hs.grid.ui.textColor = {1,1,1}`
--- * `hs.grid.ui.cellColor = {0,0,0,0.25}`
--- * `hs.grid.ui.cellStrokeColor = {0,0,0}`
--- * `hs.grid.ui.selectedColor = {0.2,0.7,0,0.4}` -- for the first selected cell during a modal resize
--- * `hs.grid.ui.highlightColor = {0.8,0.8,0,0.5}` -- to highlight the frontmost window behind the grid
--- * `hs.grid.ui.highlightStrokeColor = {0.8,0.8,0,1}`
--- * `hs.grid.ui.cyclingHighlightColor = {0,0.8,0.8,0.5}` -- to highlight the window to be resized, when cycling among windows
--- * `hs.grid.ui.cyclingHighlightStrokeColor = {0,0.8,0.8,1}`
---
--- The following variables must be numbers (in screen points):
--- * `hs.grid.ui.textSize = 200`
--- * `hs.grid.ui.cellStrokeWidth = 5`
--- * `hs.grid.ui.highlightStrokeWidth = 30`
---
--- The following variables must be strings:
--- * `hs.grid.ui.fontName = 'Lucida Grande'`
---
--- The following variables must be booleans:
--- * `hs.grid.ui.showExtraKeys = true` -- show non-grid keybindings in the center of the grid
local ui = {
textColor={1,1,1},
textSize=200,
cellStrokeColor={0,0,0},
cellStrokeWidth=5,
cellColor={0,0,0,0.25},
highlightColor={0.8,0.8,0,0.5},
highlightStrokeColor={0.8,0.8,0,1},
cyclingHighlightColor={0,0.8,0.8,0.5},
cyclingHighlightStrokeColor={0,0.8,0.8,1},
highlightStrokeWidth=30,
selectedColor={0.2,0.7,0,0.4},
showExtraKeys=true,
fontName='Lucida Grande'
}
local uielements -- drawing objects
local resizing -- modal "hotkey"
deleteUI=function()
if not uielements then return end
for _,s in pairs(uielements) do
s.howto.rect:delete() s.howto.text:delete()
for _,e in pairs(s.hints) do
e.rect:delete() e.text:delete()
end
end
uielements = nil
_HINTS=nil
end
grid.ui=setmetatable({},{__newindex=function(_,k,v) ui[k]=v deleteUI()end,__index=ui})
local function makeHints() -- quick hack to double up rows (for portrait screens mostly)
if _HINTS then return end
_HINTS={}
local rows=#grid.HINTS
for i,v in ipairs(grid.HINTS) do _HINTS[i]=v _HINTS[i+rows]={} end -- double up the hints
for y=1,rows do
for x,_ in ipairs(_HINTS[y]) do
_HINTS[y+rows][x] = ''.._HINTS[y][x] -- add shift
end
end
end
local function makeUI()
local ts,tsh=ui.textSize,ui.textSize*0.5
deleteUI()
makeHints()
uielements = {}
local screens = screen.allScreens()
local function dist(i,w1,w2) return round((i-1)/w1*w2)+1 end
for i,theScreen in ipairs(screens) do
local sgr = getGrid(theScreen)
local cell = getCellSize(theScreen)
local frame = getGridFrame(theScreen)
log.f('Screen #%d %s (%s) -> grid %s (%s cells)',i,theScreen:name(),frame.size.string,sgr.string,cell:floor().string)
local htf = {w=550,h=150}
htf.x = frame.x+frame.w/2-htf.w/2 htf.y = frame.y+frame.h/2-htf.h/3*2
if fmod(sgr.h,2)==1 then htf.y=htf.y-cell.h/2 end
local howtorect = drawing.rectangle(htf)
howtorect:setFill(true) howtorect:setFillColor(getColor(ui.cellColor)) howtorect:setStrokeWidth(ui.cellStrokeWidth)
local howtotext=drawing.text(htf,' ←→↑↓:select screen\n ⇥:next win ⇧⇥:prev win\n space:fullscreen esc:exit')
howtotext:setTextSize(40) howtotext:setTextColor(getColor(ui.textColor))
howtotext:setTextFont(ui.fontName)
local sid=theScreen:id()
uielements[sid] = {left=(theScreen:toWest() or theScreen):id(),
up=(theScreen:toNorth() or theScreen):id(),
right=(theScreen:toEast() or theScreen):id(),
down=(theScreen:toSouth() or theScreen):id(),
screen=theScreen, frame=frame,
howto={rect=howtorect,text=howtotext},
hints={}}
-- create the ui for cells
local hintsw,hintsh = #_HINTS[1],#_HINTS
for hx=min(hintsw,sgr.w),1,-1 do
local cx,cx2 = hx,hx+1
-- allow for grid width > # available hint columns
if sgr.w>hintsw then cx=dist(cx,hintsw,sgr.w) cx2=dist(cx2,hintsw,sgr.w) end
local x,x2 = frame.x+cell.w*(cx-1),frame.x+cell.w*(cx2-1)
for hy=min(hintsh,sgr.h),1,-1 do
local cy,cy2 = hy,hy+1
-- allow for grid heigth > # available hint rows
if sgr.h>hintsh then cy=dist(cy,hintsh,sgr.h) cy2=dist(cy2,hintsh,sgr.h) end
local y,y2 = frame.y+cell.h*(cy-1),frame.y+cell.h*(cy2-1)
local elem = geom.new{x=x,y=y,x2=x2,y2=y2}
local rect = drawing.rectangle(elem)
rect:setFill(true) rect:setFillColor(getColor(ui.cellColor))
rect:setStroke(true) rect:setStrokeColor(getColor(ui.cellStrokeColor)) rect:setStrokeWidth(ui.cellStrokeWidth)
elem.rect = rect
elem.hint = _HINTS[_HINTROWS[min(sgr.h,hintsh)][hy]][hx]
local tw=ts*ulen(elem.hint)
local text=drawing.text({x=x+(x2-x)/2-tw/2,y=y+(y2-y)/2-tsh,w=tw,h=ts*1.1},elem.hint)
text:setTextSize(ts) text:setTextFont(ui.fontName)
text:setTextColor(getColor(ui.textColor))
elem.text=text
log.vf('[%d] %s %.0f,%.0f>%.0f,%.0f',i,elem.hint,elem.x,elem.y,elem.x2,elem.y2)
tinsert(uielements[sid].hints,elem)
end
end
end
end
local function showGrid(id)
if not id or not uielements[id] then log.e('Cannot get current screen, aborting') return end
local elems = uielements[id].hints
for _,e in ipairs(elems) do e.rect:show() e.text:show() end
if ui.showExtraKeys then uielements[id].howto.rect:show() uielements[id].howto.text:show() end
end
local function hideGrid(id)
if not id or not uielements or not uielements[id] then --[[log.e('Cannot obtain current screen') --]] return end
uielements[id].howto.rect:hide() uielements[id].howto.text:hide()
local elems = uielements[id].hints
for _,e in pairs(elems) do e.rect:hide() e.text:hide() end
end
local initialized, showing, currentScreen, exitCallback
local currentWindow, currentWindowIndex, allWindows, cycledWindows, focusedWindow, reorderIndex, cycling, highlight
local function startCycling()
allWindows=window.orderedWindows() cycledWindows={} reorderIndex=1 focusedWindow=currentWindow
local cid=currentWindow:id()
for i,w in ipairs(allWindows) do
if w:id()==cid then currentWindowIndex=i break end
end
--[[focus the desktop so the windows can :raise
local finder=application.find'Finder'
for _,w in ipairs(finder:allWindows()) do
if w:role()=='AXScrollArea' then w:focus() return end
end--]]
end
local function _start()
if initialized then return end
screen.watcher.new(deleteUI):start()
require'hs.spaces'.watcher.new(grid.hide):start()
resizing=newmodal()
local function showHighlight()
if highlight then highlight:delete() end
highlight = drawing.rectangle(currentWindow:frame())
highlight:setFill(true) highlight:setFillColor(getColor(cycling and ui.cyclingHighlightColor or ui.highlightColor)) highlight:setStroke(true)
highlight:setStrokeColor(getColor(cycling and ui.cyclingHighlightStrokeColor or ui.highlightStrokeColor)) highlight:setStrokeWidth(ui.highlightStrokeWidth)
highlight:show()
end
function resizing:entered() -- luacheck: ignore
if showing then return end
if window.layout._hasActiveInstances then window.layout.pauseAllInstances() end
-- currentWindow = window.frontmostWindow()
if not currentWindow then log.w('Cannot get current window, aborting') resizing:exit() return end
log.df('Start moving %s [%s]',currentWindow:subrole(),currentWindow:application():title())
if currentWindow:isFullScreen() then currentWindow:setFullScreen(false) --[[resizing:exit()--]] end
-- disallow resizing fullscreen windows as it doesn't really make much sense
-- so fullscreen window gets toggled back first
currentScreen = (currentWindow:screen() or screen.mainScreen()):id()
showHighlight()
if not uielements then makeUI() end
showGrid(currentScreen)
showing = true
end
-- selectedCorner gives us the corner the user selected first with a hint
-- By this we know if we have to add or insert or remove a column/row to the selectedMatrix
-- 0 = upper-left; 1 = upper-right; 2 = bottom-left; 3 = bottom-right
local selectedCorner = 0
-- selectedMatrix keeps track of the cells the user navigated to
local selectedMatrix = {{}}
-- dim = {x,y}; x = #columns; y = #rows
local dim = {1,1}
-- Clear selected cells
local function clearSelection()
if selectedMatrix[1][1] then
for _,row in ipairs(selectedMatrix) do
for _,cell in ipairs(row) do cell.rect:setFillColor(getColor(ui.cellColor)) end
end
end
-- reset all matrix values
selectedCorner = 0
selectedMatrix = {{}}
dim = {1,1}
end
function resizing:exited() -- luacheck: ignore
if not showing then return true end
if highlight then highlight:delete() highlight=nil end
clearSelection()
if cycling and #allWindows>0 then
-- will STILL somewhat mess up window order, because orderedWindows~=most recently focused windows; but oh well
for i=reorderIndex,1,-1 do if cycledWindows[i] then allWindows[i]:focus() timer.usleep(80000) end end
if focusedWindow then focusedWindow:focus() end
end
hideGrid(currentScreen)
showing = nil
if window.layout._hasActiveInstances then window.layout.resumeAllInstances() end
if type(exitCallback)=='function' then return exitCallback() end
end
local function cycle(d)
if not cycling then cycling=true startCycling() currentWindowIndex=currentWindowIndex-d end
clearSelection() hideGrid(currentScreen)
local startIndex=currentWindowIndex
repeat
currentWindowIndex=(currentWindowIndex+d) % #allWindows
if currentWindowIndex==0 then currentWindowIndex=#allWindows end
currentWindow = allWindows[currentWindowIndex]
until currentWindowIndex==startIndex or currentWindow:subrole()=='AXStandardWindow'
reorderIndex=max(reorderIndex,currentWindowIndex)
currentWindow:focus()
cycledWindows[currentWindowIndex]=true
currentScreen=(currentWindow:screen() or screen.mainScreen()):id()
showHighlight()
showGrid(currentScreen)
end
-- gets the neighbour cell in a certain direction
local function getNeighbour(elem, dir)
-- neighbour can perfectly be found by simple geom calculation
local nx,ny -- x and y values of the neighbour cell
if (dir == 'right') then
nx = elem.x + elem.w
ny = elem.y
elseif (dir == 'left') then
nx = elem.x - elem.w
ny = elem.y
elseif (dir == 'up') then
nx = elem.x
ny = elem.y - elem.h
elseif (dir == 'down') then
nx = elem.x
ny = elem.y + elem.h
end
for _,cell in ipairs(uielements[currentScreen].hints) do
if (nx == cell.x and ny == cell.y) then return cell end
end
-- no cell found, you'r going out of your screen!
return nil
end
-- key bindings, events at certain non-hint key presses
resizing:bind({},'tab',function()cycle(1)end)
resizing:bind({'shift'},'tab',function()cycle(-1)end)
resizing:bind({},'delete',clearSelection)
resizing:bind({},'escape',function()log.d('abort move')resizing:exit()end)
resizing:bind({},'return',function()
if not selectedMatrix[1][1] then return
-- move and resize to highlighted cells
elseif dim[1] > 1 or dim[2] > 1 then
local x1,x2,y1,y2
local selectedElem = selectedMatrix[1][1]
local elem = selectedMatrix[dim[2]][dim[1]]
x1,x2 = min(selectedElem.x,elem.x)+margins.w,max(selectedElem.x,elem.x)-margins.h
y1,y2 = min(selectedElem.y,elem.y)+margins.w,max(selectedElem.y,elem.y)-margins.h
local frame={x=x1,y=y1,w=x2-x1+elem.w,h=y2-y1+elem.h}
currentWindow:setFrameInScreenBounds(frame)
log.f('move to %.0f,%.0f[%.0fx%.0f] by navigation',frame.x,frame.y,frame.w,frame.h)
clearSelection()
if cycling then cycle(1) else resizing:exit() end
-- one element selected, do a pure move
else
local selectedElem = selectedMatrix[1][1]
local x1,y1 = selectedElem.x+margins.w,selectedElem.y+margins.w
currentWindow:setFrame(geom({x1, y1}, currentWindow:size()))
clearSelection()
if cycling then cycle(1) else resizing:exit() end
end
end)
resizing:bind({},'space',function()
-- local wasfs=currentWindow:isFullScreen()
log.d('toggle fullscreen')currentWindow:toggleFullScreen()
if currentWindow:isFullScreen() then resizing:exit()
-- elseif not wasfs then currentWindow:setFrame(currentWindow:screen():frame(),0) resizing:exit()
end
end)
for _,dir in ipairs({'left','right','up','down'}) do
resizing:bind({},dir,function()
if not selectedMatrix[1][1] then
-- arrows are in screen selecting mode
log.d('select screen '..dir)
clearSelection() hideGrid(currentScreen)
currentScreen=uielements[currentScreen][dir]
currentWindow:moveToScreen(uielements[currentScreen].screen,0)
showHighlight()
showGrid(currentScreen)
else
-- once one cell is selected, the arrows will navigate to other cells
-- check for transition of position of the first selected cell in the matrix
if dim[2] == 1 then
-- checks for only one cell in selectedMatrix; dim == {1,1}
if dim[1] == 1 and dir == 'left' then
selectedCorner = 1
elseif dim[1] == 1 and dir == 'right' then
selectedCorner = 0
elseif dim[1] == 1 and dir == 'down' then
selectedCorner = 0
elseif dim[1] == 1 and dir == 'up' then
selectedCorner = 2
-- multiple cells in the matrix
elseif ( selectedCorner == 0 or selectedCorner == 2 ) and dir == 'up' then
selectedCorner = 2
elseif ( selectedCorner == 1 or selectedCorner == 3 ) and dir == 'up' then
selectedCorner = 3
elseif ( selectedCorner == 0 or selectedCorner == 2 ) and dir == 'down' then
selectedCorner = 0
elseif ( selectedCorner == 1 or selectedCorner == 3 ) and dir == 'down' then
selectedCorner = 1
end
elseif dim[1] == 1 then
if ( selectedCorner == 0 or selectedCorner == 1 ) and dir == 'right' then
selectedCorner = 0
elseif ( selectedCorner == 2 or selectedCorner == 3 ) and dir == 'right' then
selectedCorner = 2
elseif ( selectedCorner == 0 or selectedCorner == 1 ) and dir == 'left' then
selectedCorner = 1
elseif ( selectedCorner == 2 or selectedCorner == 3 ) and dir == 'left' then
selectedCorner = 3
end
end
-- In case of valid next cell, add them to the matrix and fill the rectangle
if dir == 'right' then
if selectedCorner == 0 or selectedCorner == 2 then
-- add extra column
for i=1,dim[2] do
local lastInRow = selectedMatrix[i][dim[1]]
local newElem = getNeighbour(lastInRow, 'right')
-- getNeighbour() can return nil when you run out of screen
if newElem == nil then return end
-- if valid neighbour, add it to the matrix
selectedMatrix[i][dim[1] + 1] = newElem
-- and color the cell
newElem.rect:setFillColor(getColor(ui.selectedColor))
end
dim[1] = dim[1] + 1
else
-- if selectedCorner == 1 or selectedCorner == 2
-- remove first column, only if more than one column left in matrix!
if dim[1] > 1 then
for i=1,dim[2] do
selectedMatrix[i][1].rect:setFillColor(getColor(ui.cellColor))
table.remove(selectedMatrix[i], 1)
end
dim[1] = dim[1] - 1
end
end
elseif dir == 'left' then
if selectedCorner == 0 or selectedCorner == 2 then
-- remove last column
if dim[1] > 1 then
for i=1,dim[2] do
selectedMatrix[i][dim[1]].rect:setFillColor(getColor(ui.cellColor))
table.remove(selectedMatrix[i], dim[1])
end
dim[1] = dim[1] - 1
end
else
-- insert column
for i=1,dim[2] do
local firstInRow = selectedMatrix[i][1]
local newElem = getNeighbour(firstInRow, 'left')
if newElem == nil then return end
table.insert(selectedMatrix[i], 1, newElem)
newElem.rect:setFillColor(getColor(ui.selectedColor))
end
dim[1] = dim[1] + 1
end
elseif dir == 'down' then
if selectedCorner == 0 or selectedCorner == 1 then
-- add/append row
selectedMatrix[dim[2] + 1] = {}
for i=1,dim[1] do
local lastInColumn = selectedMatrix[dim[2]][i]
local newElem = getNeighbour(lastInColumn, 'down')
if newElem == nil then return end
selectedMatrix[dim[2] + 1][i] = getNeighbour(lastInColumn, 'down')
newElem.rect:setFillColor(getColor(ui.selectedColor))
end
dim[2] = dim[2] + 1
else
-- delete first row
if dim[2] > 1 then
for i=1,dim[1] do
selectedMatrix[1][i].rect:setFillColor(getColor(ui.cellColor))
end
table.remove(selectedMatrix, 1)
dim[2] = dim[2] - 1
end
end
elseif dir == 'up' then
if selectedCorner == 0 or selectedCorner == 1 then
-- delete last row
if dim[2] > 1 then
for i=1,dim[1] do
selectedMatrix[dim[2]][i].rect:setFillColor(getColor(ui.cellColor))
end
table.remove(selectedMatrix, dim[2])
dim[2] = dim[2] - 1
end
else
-- insert row
table.insert(selectedMatrix, 1, {})
for i=1,dim[1] do
local firstInColumn = selectedMatrix[2][i]
local newElem = getNeighbour(firstInColumn, 'up')
if newElem == nil then return end
selectedMatrix[1][i] = newElem
newElem.rect:setFillColor(getColor(ui.selectedColor))
end
dim[2] = dim[2] + 1
end
end
end
end)
end
local function hintPressed(c)
-- find the elem; if there was a way to unbind modals, we'd unbind on screen change, and pass here the elem directly
local elem
for _,hint in ipairs(uielements[currentScreen].hints) do
if hint.hint==c then elem=hint break end
end
-- local elem = fnutils.find(uielements[currentScreen].hints,function(e)return e.hint==c end)
if not elem then return end
if selectedMatrix[1][1] == nil then
selectedMatrix[1][1] = elem
elem.rect:setFillColor(getColor(ui.selectedColor))
else
local x1,x2,y1,y2
local selectedElem = selectedMatrix[1][1]
x1,x2 = min(selectedElem.x,elem.x)+margins.w,max(selectedElem.x,elem.x)-margins.h
y1,y2 = min(selectedElem.y,elem.y)+margins.w,max(selectedElem.y,elem.y)-margins.h
local frame={x=x1,y=y1,w=x2-x1+elem.w,h=y2-y1+elem.h}
currentWindow:setFrameInScreenBounds(frame)
log.f('move to %.0f,%.0f[%.0fx%.0f]',frame.x,frame.y,frame.w,frame.h)
clearSelection()
if cycling then cycle(1) else resizing:exit() end
end
end
makeHints()
for _,row in ipairs(_HINTS) do
for _,c in ipairs(row) do
local key,mod=c,''
if ssub(c,1,3)=='' then key,mod=ssub(c,4),'' end -- re: "quick hack" @makeHints()
resizing:bind(mod,key,function()hintPressed(c) end)
end
end
initialized=true
end
function grid.show(cb,stay)
if showing then return end
if type(cb)=='boolean' then stay=cb cb=nil end
exitCallback=cb
if not initialized then _start() end
cycling=stay and true or nil
-- there will be some inconsistency when cycling (focusedWindow~=frontmost), but oh well
currentWindowIndex,currentWindow=1,window.frontmostWindow()
if cycling then startCycling() end
-- else resizing:exit() end
resizing:enter()
end
function grid.hide()
if showing then resizing:exit() end
end
function grid.toggleShow(cb,stay)
if showing then grid.hide() else grid.show(stay,cb) end
end
-- Legacy stuff below, deprecated
setmetatable(grid,{
__index = function(t,k)
if k=='GRIDWIDTH' then return gridSizes[true].w
elseif k=='GRIDHEIGHT' then return gridSizes[true].h
elseif k=='MARGINX' then return margins.w
elseif k=='MARGINY' then return margins.h
else return rawget(t,k) end
end,
__newindex = function(t,k,v)
if k=='GRIDWIDTH' then grid.setGrid{w=v,h=gridSizes[true].h}
elseif k=='GRIDHEIGHT' then grid.setGrid{w=gridSizes[true].w,h=v}
elseif k=='MARGINX' then grid.setMargins{v,margins.h}
elseif k=='MARGINY' then grid.setMargins{margins.w,v}
else rawset(t,k,v) end
end,
}) -- metatable for legacy variables
-- deprecate these too
function grid.adjustNumberOfRows(delta)
grid.GRIDHEIGHT = max(1, grid.GRIDHEIGHT + delta)
require'hs.fnutils'.map(window.visibleWindows(), grid.snap)
end
function grid.adjustNumberOfColumns(delta)
grid.GRIDWIDTH = max(1, grid.GRIDWIDTH + delta)
require'hs.fnutils'.map(window.visibleWindows(), grid.snap)
end
-- these are now doubly-deprecated :)
grid.adjustHeight = grid.adjustNumberOfRows
grid.adjustWidth = grid.adjustNumberOfColumns
return grid