đź“–   Chapter 16

Resize and move windows with the mouse

Click and drag anywhere in a window while holding modifiers to resize or move it.

Next Chapter
APIs used today Description
hs.alertDraws an alert message on the screen.
hs.canvasThis lets you draw anything you want to the screen.
hs.eventtapLets you listen for keypresses and respond to them.
hs.fnutilsA collection of functional programming utility functions.
hs.geometryA coordinate struct of (x, y, w, h) with attached math functions.
hs.mouseInteract with the mouse pointer and position.
hs.windowManage 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.

A demo of the resizing and moving tool you'll build.

Create a new file #

First, make a config file to hold all your code for this project:

copy
touch ~/.hammerspoon/window-modifier.lua

And require it in your main config:

copy
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:

copy
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:

copy
windowModifier = WindowModifier:new()

Reload Hammerspoon and test out the new code:

  1. Hold down Command + Shift and click anywhere. You should see a popup.
  2. Hold down Control + Shift and click anywhere. You should see another popup.
You now have click detection working!

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.

Click with the modifiers held to show the transparent overlay.

Add this function to the very top of your config:

copy
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:

copy
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:

copy
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:

copy
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:

copy
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
  • 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:

copy
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:

copy
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:

copy
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!

  1. Reload Hammerspoon.
  2. Open the Hammerspoon console.
  3. Open another window (Chrome?) next to it.
  4. Mouse over the Chrome window.
  5. While holding Command + Shift, click the window and drag the mouse.
  6. You should see dx = ..., dy = ... values being printed in the Hammerspoon console!
Watch the console on the right to see the dx, dy values print.

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:

copy
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:

copy
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!

  1. Reload Hammerspoon.
  2. Open another window (Chrome?)
  3. Mouse over the Chrome window.
  4. While holding Command + Shift, click the window and drag the mouse.
  5. You should see the transparent overlay move around the screen.
Now you have the overlay moving around with the mouse.

Magic! Next, let’s do resizing.

Handle resizing #

Again, add this next function anywhere after your local WindowModifier definition:

copy
function WindowModifier:isResizing()
  return self.dragType == dragTypes.resize
end

Then, modify handleDrag to resize the overlay when dragged by adding the code in green:

copy
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!

  1. Reload Hammerspoon.
  2. Open another window (Chrome?)
  3. Mouse over the Chrome window.
  4. While holding Control + Shift, click the window and drag the mouse.
  5. You should see the transparent overlay resize as you drag.
Now you have the overlay resizing.

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 and dragging 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:

copy
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:

copy
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:

copy
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:

  1. Reload Hammerspoon.
  2. Open another window (Chrome?)
  3. Mouse over the Chrome window.
  4. While holding Command + Shift, click the window and drag the mouse.
  5. You should see the transparent overlay move around the screen.
  6. Let go of the mouse.
  7. You should see a Cancelled drag. alert pop up.
The alert pops up when you let go of the mouse click.

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:

copy
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:

copy
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!

  1. Reload Hammerspoon.
  2. Open another window (Chrome?)
  3. Mouse over the Chrome window.
  4. While holding Control + Shift, click the window and drag the mouse.
  5. You should see the transparent overlay resize.
  6. Let go of the mouse.
  7. The Chrome window should resize itself to the new size of the overlay.
Resizing works!

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:

copy
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:

copy
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!

  1. Reload Hammerspoon.
  2. Open another window (Chrome?)
  3. Mouse over the Chrome window.
  4. While holding Command + Shift, click the window and drag the mouse.
  5. You should see the transparent overlay move.
  6. Let go of the mouse.
  7. The Chrome window should move itself to the new position of the overlay.
Moving works!

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:

copy
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!

No more selection bugs when you click and drag.

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:

copy
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:

copy
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:

copy
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:

copy
windowModifier = WindowModifier:new()
windowModifier = WindowModifier:new({
  disabledApps = { 'Alacritty' }, -- turn this off for this programs
})
When I try to resize or move the terminal, we don't allow it.

That should be it! Reload Hammerspoon and try resizing or moving one of your disabledApps. You should see it stay put.

Text expander