Resize and move windows with the mouse
Click and drag anywhere in a window while holding modifiers to resize or move it.
Next ChapterAPIs used today | Description |
hs.alert | Draws an alert message on the screen. |
hs.canvas | This lets you draw anything you want to the screen. |
hs.eventtap | Lets you listen for keypresses and respond to them. |
hs.fnutils | A collection of functional programming utility functions. |
hs.geometry | A coordinate struct of (x, y, w, h) with attached math functions. |
hs.mouse | Interact with the mouse pointer and position. |
hs.window | Manage window positions, sizes, and visibility states. |
What you’re building #
In this chapter, you’re going to build a clone of BetterTouchTool or Zooom2 that resizes or moves a window when it’s clicked and dragged while holding down modifier keys. When you hold Command+Shift and click a window, it will move as it’s dragged. When you hold Control+Shift and click a window, it will resize as it’s dragged.
Create a new file #
First, make a config file to hold all your code for this project:

touch ~/.hammerspoon/window-modifier.lua
And require it in your main config:

require("window-modifier")
Handle window clicks #
The first thing you’re going to set up is a click handler. When someone clicks a window while holding down modifiers, you’ll want to enable a drag mode lets them resize the window. The first step is to detect a click while modifiers are being held.
Modifiers held | Drag mode |
---|---|
cmd , shift |
Move the window around |
ctrl , shift |
Resize the window |
You’re going to create:
- An enum for both
dragTypes
(move, resize) - A new
WindowModifier
object that you can put your new code in - A new
clickHandler
for detecting click events - An alert popup when either of your modifier+click events occur
Paste this code into your new config file:

local dragTypes = {
move = 1,
resize = 2,
}
local WindowModifier = {}
function WindowModifier:new()
local resizer = {
dragType = nil,
dragging = false,
moveModifiers = {'cmd', 'shift'},
resizeModifiers = {'ctrl', 'shift'},
}
setmetatable(resizer, self)
self.__index = self
resizer.clickHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseDown },
resizer:handleClick()
)
resizer.clickHandler:start()
return resizer
end
function WindowModifier:handleClick()
return function(event)
local flags = event:getFlags()
local isMoving = flags:containExactly(self.moveModifiers)
local isResizing = flags:containExactly(self.resizeModifiers)
if isMoving or isResizing then
if isMoving then
self.dragType = dragTypes.move
else
self.dragType = dragTypes.resize
end
hs.alert.show("Got drag type: " .. self.dragType)
end
return false
end
end
Then, add this object initializer to the very bottom of the config:

windowModifier = WindowModifier:new()
Reload Hammerspoon and test out the new code:
- Hold down Command + Shift and click anywhere. You should see a popup.
- Hold down Control + Shift and click anywhere. You should see another popup.
Show a transparent overlay when clicked #
The next part to implement is showing a transparent overlay over the window when it’s clicked. You’ll make sure the overlay is the exact same size and position as the window that got clicked.
Add this function to the very top of your config:

local function createResizeCanvas()
local canvas = hs.canvas.new{}
canvas:insertElement(
{
id = 'opaque_layer',
action = 'fill',
type = 'rectangle',
fillColor = { red = 0, green = 0, blue = 0, alpha = 0.3 },
roundedRectRadii = { xRadius = 5.0, yRadius = 5.0 },
},
1
)
return canvas
end
Next, modify the constructor by adding the lines in green:

function WindowModifier:new()
local resizer = {
dragType = nil,
dragging = false,
moveModifiers = {'cmd', 'shift'},
resizeModifiers = {'ctrl', 'shift'},
windowCanvas = createResizeCanvas(),
targetWindow = nil,
}
setmetatable(resizer, self)
self.__index = self
resizer.clickHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseDown },
resizer:handleClick()
)
resizer.clickHandler:start()
return resizer
end
This next function searches through the list of orderedWindows()
currently on the screen, and finds the top-most window under the cursor’s (x, y)
position. We’ll use this to figure out which window you intend on targeting when you click.
At the very top of your config file, paste this function in:

local function getWindowUnderMouse()
-- Invoke `hs.application` because `hs.window.orderedWindows()` doesn't do it
-- and breaks itself
local _ = hs.application
local myPos = hs.geometry.new(hs.mouse.absolutePosition())
local myScreen = hs.mouse.getCurrentScreen()
return hs.fnutils.find(hs.window.orderedWindows(), function(w)
return myScreen == w:screen() and myPos:inside(w:frame())
end)
end
This next function resizes our transparent overlay canvas to the exact size and position of the targetWindow
being clicked on.
Paste this function anywhere after the local WindowModifier
definition in your config file:

function WindowModifier:resizeCanvasToWindow()
local position = self.targetWindow:topLeft()
local size = self.targetWindow:size()
self.windowCanvas:topLeft({ x = position.x, y = position.y })
self.windowCanvas:size({ w = size.w, h = size.h })
end
Finally, you’ll update the click handler to:
- Find the target window under the mouse cursor
- Resize and position the transparent overlay on top of the target window
- Show the overlay above the target window
Modify the handleClick()
function by removing the lines in red and adding the lines in green:

function WindowModifier:handleClick()
return function(event)
local flags = event:getFlags()
local isMoving = flags:containExactly(self.moveModifiers)
local isResizing = flags:containExactly(self.resizeModifiers)
if isMoving or isResizing then
if isMoving then
self.dragType = dragTypes.move
else
self.dragType = dragTypes.resize
end
hs.alert.show("Got drag type: " .. self.dragType)
local currentWindow = getWindowUnderMouse()
self.dragging = true
self.targetWindow = currentWindow
if isMoving then
self.dragType = dragTypes.move
else
self.dragType = dragTypes.resize
end
-- Resize the canvas to the current window's size, and position it
-- directly above the window.
self:resizeCanvasToWindow()
-- Show the canvas overlay above the window.
self.windowCanvas:show()
end
return false
end
end
Add a drag handler #
The next task is to add a drag handler. You’ll give it the following properties:
- You’ll enable the handler after a modifier+click is detected
- It will fire every time the mouse moves
- Depending on the
dragType
, you’ll do different things:- For a
resize
, you’ll resize the transparent overlay in place - For a
move
, you’ll move the transparent overlay in whatever direction the mouse is moving
- For a
- Once the mouse is let go, you’ll disable the drag handler
To do so, you can create another hs.eventtap
that listens for the leftMouseDragged
event.
Paste this function after the local WindowModifier
definition in your config file:

function WindowModifier:handleDrag()
return function(event)
if not self.dragging then return nil end
local dx = event:getProperty(hs.eventtap.event.properties.mouseEventDeltaX)
local dy = event:getProperty(hs.eventtap.event.properties.mouseEventDeltaY)
p("Moved: dx = " .. tostring(dx) .. ", dy = " .. tostring(dy))
end
end
Next, update your constructor to create the new hs.eventtap
and attach the new handleDrag
function you created to it by adding the lines in green:

function WindowModifier:new()
local resizer = {
dragType = nil,
dragging = false,
moveModifiers = {'cmd', 'shift'},
resizeModifiers = {'ctrl', 'shift'},
windowCanvas = createResizeCanvas(),
targetWindow = nil,
}
setmetatable(resizer, self)
self.__index = self
resizer.clickHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseDown },
resizer:handleClick()
)
resizer.dragHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseDragged },
resizer:handleDrag()
)
resizer.clickHandler:start()
return resizer
end
Finally, make a slight modification to your click handler to enable the drag handler as soon as a drag is detected, by adding the lines in green:

function WindowModifier:handleClick()
return function(event)
if self.dragging then return true end
local flags = event:getFlags()
local isMoving = flags:containExactly(self.moveModifiers)
local isResizing = flags:containExactly(self.resizeModifiers)
if isMoving or isResizing then
local currentWindow = getWindowUnderMouse()
self.dragging = true
self.targetWindow = currentWindow
if isMoving then
self.dragType = dragTypes.move
else
self.dragType = dragTypes.resize
end
-- Resize the canvas to the current window's size, and position it
-- directly above the window.
self:resizeCanvasToWindow()
-- Show the canvas overlay above the window.
self.windowCanvas:show()
-- Start the drag handler.
self.dragHandler:start()
else
return false
end
end
end
Test it out!
- Reload Hammerspoon.
- Open the Hammerspoon console.
- Open another window (Chrome?) next to it.
- Mouse over the Chrome window.
- While holding Command + Shift, click the window and drag the mouse.
- You should see
dx = ..., dy = ...
values being printed in the Hammerspoon console!
Now that the foundation of the drag handler is working, it’s time to actually make it move and resize!
Handle moving #
The first drag type to handle is window movement.
First, add this function anywhere after your local WindowModifier
definition:

function WindowModifier:isMoving()
return self.dragType == dragTypes.move
end
We’ll use this function later to check if the current dragType
is a move
operation or not.
Next, update your handleDrag()
function to move the transparent overlay if isMoving()
is true
. Replace the code in red with the code in green inside your handleDrag()
function:

function WindowModifier:handleDrag()
return function(event)
if not self.dragging then return nil end
local dx = event:getProperty(hs.eventtap.event.properties.mouseEventDeltaX)
local dy = event:getProperty(hs.eventtap.event.properties.mouseEventDeltaY)
p("Moved: dx = " .. tostring(dx) .. ", dy = " .. tostring(dy))
if self:isMoving() then
local current = self.windowCanvas:topLeft()
self.windowCanvas:topLeft({
x = current.x + dx,
y = current.y + dy,
})
return true
else
return false
end
end
end
Test it out!
- Reload Hammerspoon.
- Open another window (Chrome?)
- Mouse over the Chrome window.
- While holding Command + Shift, click the window and drag the mouse.
- You should see the transparent overlay move around the screen.
Magic! Next, let’s do resizing.
Handle resizing #
Again, add this next function anywhere after your local WindowModifier
definition:

function WindowModifier:isResizing()
return self.dragType == dragTypes.resize
end
Then, modify handleDrag
to resize the overlay when dragged by adding the code in green:

function WindowModifier:handleDrag()
return function(event)
if not self.dragging then return nil end
local dx = event:getProperty(hs.eventtap.event.properties.mouseEventDeltaX)
local dy = event:getProperty(hs.eventtap.event.properties.mouseEventDeltaY)
if self:isMoving() then
local current = self.windowCanvas:topLeft()
self.windowCanvas:topLeft({
x = current.x + dx,
y = current.y + dy,
})
return true
elseif self:isResizing() then
local currentSize = self.windowCanvas:size()
self.windowCanvas:size({
w = currentSize.w + dx,
h = currentSize.h + dy
})
return true
else
return false
end
end
end
Test it out!
- Reload Hammerspoon.
- Open another window (Chrome?)
- Mouse over the Chrome window.
- While holding Control + Shift, click the window and drag the mouse.
- You should see the transparent overlay resize as you drag.
Add a cancel handler #
The final piece we need is a cancel handler to fire when the mouse is let go of. This will let you do a few things:
- Reset all the event handlers to wait for your next click + drag.
- Reset the
dragType
anddragging
flag. - Hide the transparent overlay.
- Actually perform the resize or move operation on the
targetWindow
.- Until now, you’ve probably noticed that you’ve only been resizing and moving the overlay.
You’re going to add a stop()
function to reset all of your state. You’ll also add a handleCancel()
function that you can attach to a leftMouseUp
event, to do your resetting + operations inside of.
Paste this code anywhere after your local WindowModifier
definition:

function WindowModifier:stop()
self.dragging = false
self.dragType = nil
self.windowCanvas:hide()
self.cancelHandler:stop()
self.dragHandler:stop()
self.clickHandler:start()
end
function WindowModifier:handleCancel()
return function()
if not self.dragging then return end
hs.alert.show("Cancelled drag.")
self:stop()
end
end
Next, modify your constructor to create a new drag handler by adding the lines in green:

function WindowModifier:new()
local resizer = {
dragType = nil,
dragging = false,
moveModifiers = {'cmd', 'shift'},
resizeModifiers = {'ctrl', 'shift'},
windowCanvas = createResizeCanvas(),
targetWindow = nil,
}
setmetatable(resizer, self)
self.__index = self
resizer.clickHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseDown },
resizer:handleClick()
)
resizer.dragHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseDragged },
resizer:handleDrag()
)
resizer.cancelHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseUp },
resizer:handleCancel()
)
resizer.clickHandler:start()
return resizer
end
Update your click handler to start the cancel handler by adding the lines in green:

function WindowModifier:handleClick()
return function(event)
if self.dragging then return true end
local flags = event:getFlags()
local isMoving = flags:containExactly(self.moveModifiers)
local isResizing = flags:containExactly(self.resizeModifiers)
if isMoving or isResizing then
local currentWindow = getWindowUnderMouse()
self.dragging = true
self.targetWindow = currentWindow
if isMoving then
self.dragType = dragTypes.move
else
self.dragType = dragTypes.resize
end
-- Resize the canvas to the current window's size, and position it
-- directly above the window.
self:resizeCanvasToWindow()
-- Show the canvas overlay above the window.
self.windowCanvas:show()
-- Start the drag handler.
self.dragHandler:start()
-- Start the cancel handler
self.cancelHandler:start()
else
return false
end
end
end
Then give it a test:
- Reload Hammerspoon.
- Open another window (Chrome?)
- Mouse over the Chrome window.
- While holding Command + Shift, click the window and drag the mouse.
- You should see the transparent overlay move around the screen.
- Let go of the mouse.
- You should see a Cancelled drag. alert pop up.
Resize the window to the canvas size #
Inside your cancel handler, you’ll perform a resize on the window if our dragType == dragTypes.resize
.
First, add a function to resize the window to the overlay’s current size by pasting this function anywhere after the local windowModifier
definition:

function WindowModifier:resizeWindowToCanvas()
if not self.targetWindow then return end
if not self.windowCanvas then return end
local size = self.windowCanvas:size()
self.targetWindow:setSize(size.w, size.h)
end
Next, update the cancel handler to perform an actual resize on the targetWindow
by removing the lines in red and adding the lines in green:

function WindowModifier:handleCancel()
return function()
if not self.dragging then return end
hs.alert.show("Cancelled drag.")
if self:isResizing() then
self:resizeWindowToCanvas()
end
self:stop()
end
end
Give it a test resize!
- Reload Hammerspoon.
- Open another window (Chrome?)
- Mouse over the Chrome window.
- While holding Control + Shift, click the window and drag the mouse.
- You should see the transparent overlay resize.
- Let go of the mouse.
- The Chrome window should resize itself to the new size of the overlay.
Move the window to the canvas position #
Next, you’ll do almost the same thing for moving the window. You’ll add a function to move the targetWindow
to the canvas’ position, and update your cancel handler to call it.
Paste this function anywhere after the local WindowModifier
definition:

function WindowModifier:moveWindowToCanvas()
if not self.targetWindow then return end
if not self.windowCanvas then return end
local frame = self.windowCanvas:frame()
local point = self.windowCanvas:topLeft()
local moveTo = {
x = point.x,
y = point.y,
w = frame.w,
h = frame.h,
}
self.targetWindow:move(hs.geometry.new(moveTo), nil, false, 0)
end
Then update your cancel handler to call your moveWindowToCanvas()
function when isMoving()
is true
, by adding the lines in green:

function WindowModifier:handleCancel()
return function()
if not self.dragging then return end
if self:isResizing() then
self:resizeWindowToCanvas()
elseif self:isMoving() then
self:moveWindowToCanvas()
end
self:stop()
end
end
Give it a test move!
- Reload Hammerspoon.
- Open another window (Chrome?)
- Mouse over the Chrome window.
- While holding Command + Shift, click the window and drag the mouse.
- You should see the transparent overlay move.
- Let go of the mouse.
- The Chrome window should move itself to the new position of the overlay.
Prevent selection when dragging #
When testing, you might have noticed that when you click and drag on a window, it selects all the text under the cursor as it goes. This is pretty awkward, so let’s patch things up so this stops occuring.
Luckily, all you have to do to prevent selection is to return true
in the click handler when moving or resizing.
Add the lines in green to your click handler:

function WindowModifier:handleClick()
return function(event)
local flags = event:getFlags()
local isMoving = flags:containExactly(self.moveModifiers)
local isResizing = flags:containExactly(self.resizeModifiers)
if isMoving or isResizing then
local currentWindow = getWindowUnderMouse()
self.dragging = true
self.targetWindow = currentWindow
if isMoving then
self.dragType = dragTypes.move
else
self.dragType = dragTypes.resize
end
-- Resize the canvas to the current window's size, and position it
-- directly above the window.
self:resizeCanvasToWindow()
-- Show the canvas overlay above the window.
self.windowCanvas:show()
-- Start the drag handler.
self.dragHandler:start()
-- Start the cancel handler
self.cancelHandler:start()
-- Prevent selection
return true
end
return false
end
end
Reload Hammerspoon and try resizing/moving again. It should now be selection free!
Allow disabling for specific apps #
I wanted a way to disable resizing for certain apps that I run in fullscreen–namely my terminal, Alacritty.
When you’re done with this section, you’ll change the line in red to the lines in green to disable any apps you want:

windowModifier = WindowModifier:new()
windowModifier = WindowModifier:new({
disabledApps = { 'Alacritty' }, -- turn this off for this programs
})
First, update your constructor to take in a disabledApps
option and save it to self.disabledApps
as a hashmap. Remove the lines in red and add the lines in green:

local function tableToMap(table)
local map = {}
for _, value in pairs(table) do
map[value] = true
end
return map
end
function WindowModifier:new()
function WindowModifier:new(options)
local resizer = {
disabledApps = tableToMap(options.disabledApps or {}),
dragType = nil,
dragging = false,
moveModifiers = {'cmd', 'shift'},
resizeModifiers = {'ctrl', 'shift'},
windowCanvas = createResizeCanvas(),
targetWindow = nil,
}
setmetatable(resizer, self)
self.__index = self
resizer.clickHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseDown },
resizer:handleClick()
)
resizer.dragHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseDragged },
resizer:handleDrag()
)
resizer.cancelHandler = hs.eventtap.new(
{ hs.eventtap.event.types.leftMouseUp },
resizer:handleCancel()
)
resizer.clickHandler:start()
return resizer
end
Next, update your click handler to return early if the window being clicked on belongs to any of the disabledApps
, by adding the code in green:

function WindowModifier:handleClick()
return function(event)
if self.dragging then return true end
local flags = event:getFlags()
local isMoving = flags:containExactly(self.moveModifiers)
local isResizing = flags:containExactly(self.resizeModifiers)
if isMoving or isResizing then
local currentWindow = getWindowUnderMouse()
if self.disabledApps[currentWindow:application():name()] then
return nil
end
self.dragging = true
self.targetWindow = currentWindow
if isMoving then
self.dragType = dragTypes.move
else
self.dragType = dragTypes.resize
end
-- Resize the canvas to the current window's size, and position it
-- directly above the window.
self:resizeCanvasToWindow()
-- Show the canvas overlay above the window.
self.windowCanvas:show()
-- Start the drag handler.
self.dragHandler:start()
-- Start the cancel handler
self.cancelHandler:start()
-- Prevent selection
return true
end
return false
end
end
Finally, update your call to WindowModifier:new()
to pass in any apps you want disabled:

windowModifier = WindowModifier:new()
windowModifier = WindowModifier:new({
disabledApps = { 'Alacritty' }, -- turn this off for this programs
})
That should be it! Reload Hammerspoon and try resizing or moving one of your disabledApps
. You should see it stay put.