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())