diff --git a/README.md b/README.md
index 195e332..214cc3b 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ Install Hammerspoon if you haven't yet. Download the [latest release here](https
Alternatively you can install it using brew:
```bash
-brew install --cask hammerspoon
+brew install --cask hammerspoon
```
#### Step 2
@@ -46,6 +46,13 @@ Go to `System Preferences > Security & Privacy > Accessibility` and make sure Ha
_If you just enabled permissions for Hammerspoon, you might need to restart the application for the permissions to take effect._
+#### Step 5 (optional)
+
+Configure the Shiftit spoon to your preference.
+
+- [Multiple window cycle sizes](https://github.com/peterklijn/hammerspoon-shiftit#configure-multiple-window-cycle-sizes) allows you to override the default 50% window size for snapping to [sides](https://github.com/peterklijn/hammerspoon-shiftit#snap-to-sides) and [corners](https://github.com/peterklijn/hammerspoon-shiftit#snap-to-corners)
+- [Override key mappings](https://github.com/peterklijn/hammerspoon-shiftit#overriding-key-mappings) allows you to override the default key bindings.
+
The ShiftIt spoon is now ready to use, enjoy.
Having issues? Check out the [Known issues](https://github.com/peterklijn/hammerspoon-shiftit#known-issues) section, have a look in the [issues section](https://github.com/peterklijn/hammerspoon-shiftit/issues), or create a new issue.
@@ -112,34 +119,52 @@ The default key mapping looks like this:
```lua
{
- left = {{ 'ctrl', 'alt', 'cmd' }, 'left' },
- right = {{ 'ctrl', 'alt', 'cmd' }, 'right' },
- up = {{ 'ctrl', 'alt', 'cmd' }, 'up' },
- down = {{ 'ctrl', 'alt', 'cmd' }, 'down' },
- upleft = {{ 'ctrl', 'alt', 'cmd' }, '1' },
- upright = {{ 'ctrl', 'alt', 'cmd' }, '2' },
- botleft = {{ 'ctrl', 'alt', 'cmd' }, '3' },
- botright = {{ 'ctrl', 'alt', 'cmd' }, '4' },
- maximum = {{ 'ctrl', 'alt', 'cmd' }, 'm' },
- toggleFullScreen = {{ 'ctrl', 'alt', 'cmd' }, 'f' },
- toggleZoom = {{ 'ctrl', 'alt', 'cmd' }, 'z' },
- center = {{ 'ctrl', 'alt', 'cmd' }, 'c' },
- nextScreen = {{ 'ctrl', 'alt', 'cmd' }, 'n' },
- previousScreen = {{ 'ctrl', 'alt', 'cmd' }, 'p' },
- resizeOut = {{ 'ctrl', 'alt', 'cmd' }, '=' },
- resizeIn = {{ 'ctrl', 'alt', 'cmd' }, '-' }
+ left = { { 'ctrl', 'alt', 'cmd' }, 'left' },
+ right = { { 'ctrl', 'alt', 'cmd' }, 'right' },
+ up = { { 'ctrl', 'alt', 'cmd' }, 'up' },
+ down = { { 'ctrl', 'alt', 'cmd' }, 'down' },
+ upleft = { { 'ctrl', 'alt', 'cmd' }, '1' },
+ upright = { { 'ctrl', 'alt', 'cmd' }, '2' },
+ botleft = { { 'ctrl', 'alt', 'cmd' }, '3' },
+ botright = { { 'ctrl', 'alt', 'cmd' }, '4' },
+ maximum = { { 'ctrl', 'alt', 'cmd' }, 'm' },
+ toggleFullScreen = { { 'ctrl', 'alt', 'cmd' }, 'f' },
+ toggleZoom = { { 'ctrl', 'alt', 'cmd' }, 'z' },
+ center = { { 'ctrl', 'alt', 'cmd' }, 'c' },
+ nextScreen = { { 'ctrl', 'alt', 'cmd' }, 'n' },
+ previousScreen = { { 'ctrl', 'alt', 'cmd' }, 'p' },
+ resizeOut = { { 'ctrl', 'alt', 'cmd' }, '=' },
+ resizeIn = { { 'ctrl', 'alt', 'cmd' }, '-' }
}
```
+### Configure multiple window cycle sizes
+
+You can configure multiple window cycle sizes by adding the following line after loading the ShiftIt spoon:
+
+```lua
+spoon.ShiftIt:setWindowCyclingSizes({ 50, 33, 67 }, { 50 })
+```
+
+The first argument (`{ 50, 33, 67 }`) sets the horizontal window cycle sizes, in the provided order.
+The second argument (`{ 50 }`) sets the vertical window cycle sizes, in this example it only sets one.
+
+The above settings will toggle the window through these steps, when repeatingly hitting `ctrl(^) + alt(⌥) + cmd(⌘) + left`:
+
+![Window Cycling Sizes visualised for left action](https://github.com/peterklijn/hammerspoon-shiftit/blob/master/images/window-cycling-sizes-visualised.png?raw=true)
+
### Overriding key mappings
You can pass the part of the key mappings that you want to override to the `bindHotkeys()` function. For example:
```lua
+-- Use Vim arrow keys
spoon.ShiftIt:bindHotkeys({
- upleft = {{ 'ctrl', 'alt', 'cmd' }, 'q' },
- upright = {{ 'ctrl', 'alt', 'cmd' }, 'w' },
-});
+ left = { { 'ctrl', 'alt', 'cmd' }, 'h' },
+ down = { { 'ctrl', 'alt', 'cmd' }, 'j' },
+ up = { { 'ctrl', 'alt', 'cmd' }, 'k' },
+ right = { { 'ctrl', 'alt', 'cmd' }, 'l' },
+})
```
## Alternative installations
diff --git a/Spoons/ShiftIt.spoon.zip b/Spoons/ShiftIt.spoon.zip
index 473d44f..55a97eb 100644
Binary files a/Spoons/ShiftIt.spoon.zip and b/Spoons/ShiftIt.spoon.zip differ
diff --git a/docs/window-cycling-sizes.html b/docs/window-cycling-sizes.html
new file mode 100644
index 0000000..f2d2304
--- /dev/null
+++ b/docs/window-cycling-sizes.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+ step 2: 33%
+
+
step 1: 50%
+
+
step 3: 67%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hammerspoon_mocks.lua b/hammerspoon_mocks.lua
index f6a527d..6eaf5e1 100644
--- a/hammerspoon_mocks.lua
+++ b/hammerspoon_mocks.lua
@@ -24,6 +24,7 @@ end
local window = {
rect = defaultWindowRect,
+ _id = 42,
_screen = screen,
}
@@ -35,6 +36,10 @@ function window:frame()
return self.rect
end
+function window:id()
+ return self._id
+end
+
function window:screen()
return self._screen
end
@@ -44,6 +49,16 @@ function window:move(rect, _, _, _)
lu.assertIsNumber(rect.y)
lu.assertIsNumber(rect.w)
lu.assertIsNumber(rect.h)
+ -- If the position contains a period (.), it is a relative coordinate
+ -- so multiply it with the screen size
+ if string.match(tostring(rect.x), '%.') ~= nil then
+ rect = {
+ x = self._screen.rect.x + (rect.x * self._screen.rect.w),
+ y = self._screen.rect.y + (rect.y * self._screen.rect.h),
+ w = rect.w * self._screen.rect.w,
+ h = rect.h * self._screen.rect.h,
+ }
+ end
self.rect = rect
end
@@ -55,6 +70,7 @@ local mocks = {
function mocks:reset()
self.hotkey.bindings = {}
self.window.rect = defaultWindowRect
+ self.window._id = 42
self.window._screen.rect = defaultScreenRect
end
diff --git a/images/window-cycling-sizes-visualised.png b/images/window-cycling-sizes-visualised.png
new file mode 100644
index 0000000..b1f541e
Binary files /dev/null and b/images/window-cycling-sizes-visualised.png differ
diff --git a/init.lua b/init.lua
index 5809158..ce1db27 100644
--- a/init.lua
+++ b/init.lua
@@ -11,7 +11,7 @@ obj.__index = obj
-- Metadata
obj.name = "HammerspoonShiftIt"
-obj.version = "1.0"
+obj.version = "1.1"
obj.author = "Peter Klijn"
obj.homepage = "https://github.com/peterklijn/hammerspoon-shiftit"
obj.license = "https://github.com/peterklijn/hammerspoon-shiftit/blob/master/LICENSE.md"
@@ -37,21 +37,55 @@ obj.mapping = {
}
local units = {
- right50 = { x = 0.50, y = 0.00, w = 0.50, h = 1.00 },
- left50 = { x = 0.00, y = 0.00, w = 0.50, h = 1.00 },
- top50 = { x = 0.00, y = 0.00, w = 1.00, h = 0.50 },
- bot50 = { x = 0.00, y = 0.50, w = 1.00, h = 0.50 },
+ left = function(x, _) return { x = 0.00, y = 0.00, w = x / 100, h = 1.00 } end,
+ right = function(x, _) return { x = 1 - (x / 100), y = 0.00, w = x / 100, h = 1.00 } end,
+ top = function(_, y) return { x = 0.00, y = 0.00, w = 1.00, h = y / 100 } end,
+ bot = function(_, y) return { x = 0.00, y = 1 - (y / 100), w = 1.00, h = y / 100 } end,
- upleft50 = { x = 0.00, y = 0.00, w = 0.50, h = 0.50 },
- upright50 = { x = 0.50, y = 0.00, w = 0.50, h = 0.50 },
- botleft50 = { x = 0.00, y = 0.50, w = 0.50, h = 0.50 },
- botright50 = { x = 0.50, y = 0.50, w = 0.50, h = 0.50 },
+ upleft = function(x, y) return { x = 0.00, y = 0.00, w = x / 100, h = y / 100 } end,
+ upright = function(x, y) return { x = 1 - (x / 100), y = 0.00, w = x / 100, h = y / 100 } end,
+ botleft = function(x, y) return { x = 0.00, y = 1 - (y / 100), w = x / 100, h = y / 100 } end,
+ botright = function(x, y) return { x = 1 - (x / 100), y = 1 - (y / 100), w = x / 100, h = y / 100 } end,
maximum = { x = 0.00, y = 0.00, w = 1.00, h = 1.00 },
}
+local latestMove = {
+ windowId = -1,
+ direction = 'unknown',
+ stepX = -1,
+ stepY = -1,
+}
+
function obj:move(unit) self.hs.window.focusedWindow():move(unit, nil, true, 0) end
+function obj:moveWithCycles(unitFn)
+ local windowId = self.hs.window.focusedWindow():id()
+ local sameMoveAction = latestMove.windowId == windowId and latestMove.direction == unitFn
+ if sameMoveAction then
+ latestMove.stepX = obj.nextCycleSizeX[latestMove.stepX]
+ latestMove.stepY = obj.nextCycleSizeY[latestMove.stepY]
+ else
+ latestMove.stepX = obj.cycleSizesX[1]
+ latestMove.stepY = obj.cycleSizesY[1]
+ end
+ latestMove.windowId = windowId
+ latestMove.direction = unitFn
+
+ local before = self.hs.window.focusedWindow():frame()
+ self:move(unitFn(latestMove.stepX, latestMove.stepY))
+
+ if not sameMoveAction then
+ -- if the window is not moved or resized, it was already at the required location,
+ -- in that case we'll call this method again, so it will go to the next cycle.
+ local after = self.hs.window.focusedWindow():frame()
+ if before.x == after.x and before.y == after.y
+ and before.w == after.w and before.h == after.h then
+ self:moveWithCycles(unitFn)
+ end
+ end
+end
+
function obj:resizeWindowInSteps(increment)
local screen = self.hs.window.focusedWindow():screen():frame()
local window = self.hs.window.focusedWindow():frame()
@@ -110,29 +144,35 @@ function obj:resizeWindowInSteps(increment)
self:move({ x = x, y = y, w = w, h = h })
end
-function obj:left() self:move(units.left50) end
+function obj:left() self:moveWithCycles(units.left) end
-function obj:right() self:move(units.right50) end
+function obj:right() self:moveWithCycles(units.right) end
-function obj:up() self:move(units.top50) end
+function obj:up() self:moveWithCycles(units.top) end
-function obj:down() self:move(units.bot50) end
+function obj:down() self:moveWithCycles(units.bot) end
-function obj:upleft() self:move(units.upleft50) end
+function obj:upleft() self:moveWithCycles(units.upleft) end
-function obj:upright() self:move(units.upright50) end
+function obj:upright() self:moveWithCycles(units.upright) end
-function obj:botleft() self:move(units.botleft50) end
+function obj:botleft() self:moveWithCycles(units.botleft) end
-function obj:botright() self:move(units.botright50) end
+function obj:botright() self:moveWithCycles(units.botright) end
-function obj:maximum() self:move(units.maximum) end
+function obj:maximum()
+ latestMove.direction = 'maximum'
+ self:move(units.maximum)
+end
function obj:toggleFullScreen() self.hs.window.focusedWindow():toggleFullScreen() end
function obj:toggleZoom() self.hs.window.focusedWindow():toggleZoom() end
-function obj:center() self.hs.window.focusedWindow():centerOnScreen(nil, true, 0) end
+function obj:center()
+ latestMove.direction = 'center'
+ self.hs.window.focusedWindow():centerOnScreen(nil, true, 0)
+ end
function obj:nextScreen()
self.hs.window.focusedWindow():moveToScreen(self.hs.window.focusedWindow():screen():next(), false, true, 0)
@@ -196,4 +236,43 @@ function obj:bindHotkeys(mapping)
return self
end
+local function join(items, separator)
+ local res = ''
+ for _, item in pairs(items) do
+ if res ~= '' then
+ res = res .. separator
+ end
+ res = res .. item
+ end
+ return res
+end
+
+function obj:setWindowCyclingSizes(stepsX, stepsY, skip_print)
+ if #stepsX < 1 or #stepsY < 1 then
+ print('Invalid arguments in setWindowCyclingSizes, both dimensions should have at least 1 step')
+ return
+ end
+ local function listToNextMap(list)
+ local res = {}
+ for i, item in ipairs(list) do
+ local prev = (list[i - 1] == nil and list[#list] or list[i - 1])
+ res[prev] = item
+ end
+ return res
+ end
+
+ self.cycleSizesX = stepsX
+ self.cycleSizesY = stepsY
+ self.nextCycleSizeX = listToNextMap(stepsX)
+ self.nextCycleSizeY = listToNextMap(stepsY)
+
+ if not skip_print then
+ print('Cycle sizes for horizontal:', join(stepsX, ' -> '))
+ print('Cycle sizes for vertical:', join(stepsY, ' -> '))
+ end
+end
+
+-- Set default steps to 50%, as it's the ShiftIt default
+obj:setWindowCyclingSizes({ 50 }, { 50 }, true)
+
return obj
diff --git a/init_test.lua b/init_test.lua
index 1605e22..8f1d96e 100644
--- a/init_test.lua
+++ b/init_test.lua
@@ -6,10 +6,11 @@ local hsmocks = require('hammerspoon_mocks')
-- luacheck: ignore 112
TestShiftIt = {} -- luacheck: ignore 111
+shiftit.hs = hsmocks
function TestShiftIt.setUp()
- shiftit.hs = hsmocks
hsmocks:reset()
+ shiftit:setWindowCyclingSizes({ 50 }, { 50 }, true)
end
function TestShiftIt.testBindDefault()
@@ -211,4 +212,149 @@ function TestShiftIt.testResizeWindowInStepsEdgeCases()
end
end
+function TestShiftIt.testInitialiseSteps()
+ lu.assertEquals(shiftit.cycleSizesX, { 50 })
+ lu.assertEquals(shiftit.cycleSizesY, { 50 })
+ lu.assertEquals(shiftit.nextCycleSizeX, { [50] = 50 })
+ lu.assertEquals(shiftit.nextCycleSizeY, { [50] = 50 })
+
+ shiftit:setWindowCyclingSizes({ 80, 60, 40 }, { 75, 50, 25 }, true)
+ lu.assertEquals(shiftit.cycleSizesX, { 80, 60, 40 })
+ lu.assertEquals(shiftit.cycleSizesY, { 75, 50, 25 })
+ lu.assertEquals(shiftit.nextCycleSizeX, { [40] = 80, [60] = 40, [80] = 60 })
+ lu.assertEquals(shiftit.nextCycleSizeY, { [25] = 75, [50] = 25, [75] =50 })
+end
+
+function TestShiftIt.testDefaultWindowShifts()
+ local tests = {
+ {
+ desc = 'shift to the left',
+ action = function () shiftit:left() end,
+ expect = { x = 0, y = 0, w = 600, h = 800 },
+ },
+ {
+ desc = 'shift to the right',
+ action = function() shiftit:right() end,
+ expect = { x = 600, y = 0, w = 600, h = 800 },
+ },
+ {
+ desc = 'shift to the top',
+ action = function() shiftit:up() end,
+ expect = { x = 0, y = 0, w = 1200, h = 400 },
+ },
+ {
+ desc = 'shift to the bottom',
+ action = function() shiftit:down() end,
+ expect = { x = 0, y = 400, w = 1200, h = 400 },
+ },
+ {
+ desc = 'shift to the left top',
+ action = function() shiftit:upleft() end,
+ expect = { x = 0, y = 0, w = 600, h = 400 },
+ },
+ {
+ desc = 'shift to the right top',
+ action = function() shiftit:upright() end,
+ expect = { x = 600, y = 0, w = 600, h = 400 },
+ },
+ {
+ desc = 'shift to the left bottom',
+ action = function() shiftit:botleft() end,
+ expect = { x = 0, y = 400, w = 600, h = 400 },
+ },
+ {
+ desc = 'shift to the right bottom',
+ action = function() shiftit:botright() end,
+ expect = { x = 600, y = 400, w = 600, h = 400 },
+ },
+ }
+ for _, test in pairs(tests) do
+ hsmocks:reset()
+ test.action()
+ lu.assertEquals(hsmocks.window.rect, test.expect, test.desc)
+ end
+end
+
+function TestShiftIt.testMultipleWindowSizeSteps()
+ shiftit:setWindowCyclingSizes({ 50, 75, 25 }, { 50 }, true)
+ local tests = {
+ {
+ desc = 'shift to the left through every option once',
+ action = function() shiftit:left() end,
+ expectSteps = {
+ { x = 0, y = 0, w = 600, h = 800 },
+ { x = 0, y = 0, w = 900, h = 800 },
+ { x = 0, y = 0, w = 300, h = 800 },
+ },
+ },
+ {
+ desc = 'shift to the right bottom through every option twice',
+ action = function() shiftit:botright() end,
+ expectSteps = {
+ { x = 600, y = 400, w = 600, h = 400 },
+ { x = 300, y = 400, w = 900, h = 400 },
+ { x = 900, y = 400, w = 300, h = 400 },
+ { x = 600, y = 400, w = 600, h = 400 },
+ { x = 300, y = 400, w = 900, h = 400 },
+ { x = 900, y = 400, w = 300, h = 400 },
+ },
+ },
+ }
+ for _, test in pairs(tests) do
+ hsmocks:reset()
+ for i, expect in pairs(test.expectSteps) do
+ test.action()
+ lu.assertEquals(hsmocks.window.rect, expect, test.desc .. '> step ' .. i)
+ end
+ end
+end
+
+function TestShiftIt.testMultipleWindowSizeStepsWithMultipleWindows()
+ shiftit:setWindowCyclingSizes({ 50, 75, 25 }, { 50 }, true)
+ local windowId1 = 99
+ local windowId2 = 88
+
+ hsmocks.window._id = windowId1
+ shiftit:left()
+ lu.assertEquals(hsmocks.window.rect, { x = 0, y = 0, w = 600, h = 800 })
+ shiftit:left()
+ lu.assertEquals(hsmocks.window.rect, { x = 0, y = 0, w = 900, h = 800 })
+
+ -- Mock switching windows, set the window size to differt from the last rect
+ hsmocks.window._id = windowId2
+ hsmocks.window.rect = { x = 100, y = 100, w = 600, h = 500 }
+ shiftit:right()
+ lu.assertEquals(hsmocks.window.rect, { x = 600, y = 0, w = 600, h = 800 })
+ shiftit:right()
+ lu.assertEquals(hsmocks.window.rect, { x = 300, y = 0, w = 900, h = 800 })
+ shiftit:right()
+ lu.assertEquals(hsmocks.window.rect, { x = 900, y = 0, w = 300, h = 800 })
+
+ -- Mock switching windows, set the window size to the last size of this window
+ hsmocks.window._id = windowId1
+ hsmocks.window.rect = { x = 0, y = 0, w = 900, h = 800 }
+ shiftit:left()
+ -- because the window was switched, we start at the beginning of the cycle, and expect the 50% value
+ lu.assertEquals(hsmocks.window.rect, { x = 0, y = 0, w = 600, h = 800 })
+
+ -- Mock switching windows, set the window size to the last size of this window
+ hsmocks.window._id = windowId2
+ hsmocks.window.rect = { x = 900, y = 0, w = 300, h = 800 }
+ shiftit:right()
+ lu.assertEquals(hsmocks.window.rect, { x = 600, y = 0, w = 600, h = 800 })
+
+ -- Mock switching windows, set the window size to the last size of this window
+ hsmocks.window._id = windowId1
+ hsmocks.window.rect = { x = 0, y = 0, w = 600, h = 800 }
+ shiftit:left()
+ -- because the window was switched, we start at the beginning of the cycle, and expect the 50% value
+ -- but because this did not cause any change, the function will call itself once, going to the 75% value
+ lu.assertEquals(hsmocks.window.rect, { x = 0, y = 0, w = 900, h = 800 })
+
+ shiftit:maximum()
+ shiftit:left()
+ -- because the window maximised, we expect the cycle to restart
+ lu.assertEquals(hsmocks.window.rect, { x = 0, y = 0, w = 600, h = 800 })
+end
+
os.exit(lu.LuaUnit.run())