SkillAgentSearch skills...

PaperWM.spoon

Tiled scrollable window manager for MacOS

Install / Use

/learn @mogenson/PaperWM.spoon
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

PaperWM.spoon

Tiled scrollable window manager for MacOS. Inspired by PaperWM.

Spoon plugin for HammerSpoon MacOS automation app.

Demo

https://user-images.githubusercontent.com/900731/147793584-f937811a-20aa-4282-baf5-035e5ddc12ea.mp4

Installation

  1. Clone to Hammerspoon Spoons directory: git clone https://github.com/mogenson/PaperWM.spoon ~/.hammerspoon/Spoons/PaperWM.spoon.

  2. Open System Preferences -> Desktop and Dock. Scroll to the bottom to "Mission Control", then uncheck "Automatically rearrange Spaces based on most recent use" and check "Displays have separate Spaces".

<img width="780" src="https://github.com/user-attachments/assets/b0842c44-2a3b-43fc-85eb-66729cd7f8db">

Install with SpoonInstall

hs.loadSpoon("SpoonInstall")

spoon.SpoonInstall.repos.PaperWM = {
    url = "https://github.com/mogenson/PaperWM.spoon",
    desc = "PaperWM.spoon repository",
    branch = "release",
}

spoon.SpoonInstall:andUse("PaperWM", {
    repo = "PaperWM",
    config = { screen_margin = 16, window_gap = 2 },
    start = true,
    hotkeys = {
        < see below >
    }
})

Usage

Add the following to your ~/.hammerspoon/init.lua:

PaperWM = hs.loadSpoon("PaperWM")
PaperWM:bindHotkeys({
    -- switch to a new focused window in tiled grid
    focus_left  = {{"alt", "cmd"}, "left"},
    focus_right = {{"alt", "cmd"}, "right"},
    focus_up    = {{"alt", "cmd"}, "up"},
    focus_down  = {{"alt", "cmd"}, "down"},

    -- switch windows by cycling forward/backward
    -- (forward = down or right, backward = up or left)
    focus_prev = {{"alt", "cmd"}, "k"},
    focus_next = {{"alt", "cmd"}, "j"},

    -- move windows around in tiled grid
    swap_left  = {{"alt", "cmd", "shift"}, "left"},
    swap_right = {{"alt", "cmd", "shift"}, "right"},
    swap_up    = {{"alt", "cmd", "shift"}, "up"},
    swap_down  = {{"alt", "cmd", "shift"}, "down"},

    -- position and resize focused window
    center_window        = {{"alt", "cmd"}, "c"},
    full_width           = {{"alt", "cmd"}, "f"},
    cycle_width          = {{"alt", "cmd"}, "r"},
    reverse_cycle_width  = {{"ctrl", "alt", "cmd"}, "r"},
    cycle_height         = {{"alt", "cmd", "shift"}, "r"},
    reverse_cycle_height = {{"ctrl", "alt", "cmd", "shift"}, "r"},

    -- increase/decrease width
    increase_width = {{"alt", "cmd"}, "l"},
    decrease_width = {{"alt", "cmd"}, "h"},

    -- move focused window into / out of a column
    slurp_in = {{"alt", "cmd"}, "i"},
    barf_out = {{"alt", "cmd"}, "o"},

    -- split screen focused window with left window
    split_screen = {{ "alt", "cmd" }, "s"},

    -- move the focused window into / out of the tiling layer
    toggle_floating = {{"alt", "cmd", "shift"}, "escape"},
    -- raise all floating windows on top of tiled windows
    focus_floating  = {{"alt", "cmd", "shift"}, "f"},

    -- focus the first / second / etc window in the current space
    focus_window_1 = {{"cmd", "shift"}, "1"},
    focus_window_2 = {{"cmd", "shift"}, "2"},
    focus_window_3 = {{"cmd", "shift"}, "3"},
    focus_window_4 = {{"cmd", "shift"}, "4"},
    focus_window_5 = {{"cmd", "shift"}, "5"},
    focus_window_6 = {{"cmd", "shift"}, "6"},
    focus_window_7 = {{"cmd", "shift"}, "7"},
    focus_window_8 = {{"cmd", "shift"}, "8"},
    focus_window_9 = {{"cmd", "shift"}, "9"},

    -- focus the leftmost / rightmost window in the current space
    focus_window_first = {{"cmd", "shift"}, "home"},
    focus_window_last  = {{"cmd", "shift"}, "end"},

    -- switch to a new Mission Control space
    switch_space_l = {{"alt", "cmd"}, ","},
    switch_space_r = {{"alt", "cmd"}, "."},
    switch_space_1 = {{"alt", "cmd"}, "1"},
    switch_space_2 = {{"alt", "cmd"}, "2"},
    switch_space_3 = {{"alt", "cmd"}, "3"},
    switch_space_4 = {{"alt", "cmd"}, "4"},
    switch_space_5 = {{"alt", "cmd"}, "5"},
    switch_space_6 = {{"alt", "cmd"}, "6"},
    switch_space_7 = {{"alt", "cmd"}, "7"},
    switch_space_8 = {{"alt", "cmd"}, "8"},
    switch_space_9 = {{"alt", "cmd"}, "9"},

    -- move focused window to a new space and tile
    move_window_l = {{ "ctrl", "alt", "cmd" }, "left"},
    move_window_r = {{ "ctrl", "alt", "cmd" }, "right"},
    move_window_u = {{ "ctrl", "alt", "cmd" }, "up"},
    move_window_d = {{ "ctrl", "alt", "cmd" }, "down"},
    move_window_1 = {{"alt", "cmd", "shift"}, "1"},
    move_window_2 = {{"alt", "cmd", "shift"}, "2"},
    move_window_3 = {{"alt", "cmd", "shift"}, "3"},
    move_window_4 = {{"alt", "cmd", "shift"}, "4"},
    move_window_5 = {{"alt", "cmd", "shift"}, "5"},
    move_window_6 = {{"alt", "cmd", "shift"}, "6"},
    move_window_7 = {{"alt", "cmd", "shift"}, "7"},
    move_window_8 = {{"alt", "cmd", "shift"}, "8"},
    move_window_9 = {{"alt", "cmd", "shift"}, "9"}
})
PaperWM:start()

Feel free to customize hotkeys or use PaperWM:bindHotkeys(PaperWM.default_hotkeys) for defaults. PaperWM actions are also available for manual keybinding. The PaperWM.actions.actions() function will return a table of action names and functions to call.

For example, the following config uses a hyper key and a modal layer to navigate windows with the h/j/k/l keys, like vim:

PaperWM = hs.loadSpoon("PaperWM")
PaperWM:bindHotkeys(PaperWM.default_hotkeys)

-- use ⌘ Enter as hyper key to enter modal layer, press Escape to exit
local modal = hs.hotkey.modal.new({ "cmd" }, "return")

local actions = PaperWM.actions.actions()
modal:bind({}, "h", nil, actions.focus_left)
modal:bind({}, "j", nil, actions.focus_down)
modal:bind({}, "k", nil, actions.focus_up)
modal:bind({}, "l", nil, actions.focus_right)
modal:bind({}, "escape", function() modal:exit() end)

PaperWM:start()

PaperWM:start() will begin automatically tiling new and existing windows. PaperWM:stop() will release control over windows.

Set PaperWM.window_gap to the number of pixels between windows and screen edges. This can be a single number for all sides, or a table specifying top, bottom, left, and right gaps individually.

For example:

-- 10px gap on all sides
PaperWM.window_gap = 10
-- or specific gaps per side
PaperWM.window_gap  =  { top = 10, bottom = 8, left = 12, right = 12 }

Third-party tools like Sketchybar can be used to create custom status bars and/or dock. Set PaperWM.external_bar to the to a table specifying top, bottom in number of pixels of your bar and dock to ensure consistent window placement on displays with and without a "notch".

For example:

-- Add 40px offset for an external status bar
PaperWM.external_bar = {top = 40}
-- or, add 20px offset for an external status bar and 40px offset for an external dock
PaperWM.external_bar = {top = 20, bottom = 40}

Configure the PaperWM.window_filter to set which apps and screens are managed. For example:

-- ignore a specific app
PaperWM.window_filter:rejectApp("iStat Menus Status")
-- ignore a specific window of an app
PaperWM.window_filter:setAppFilter("iTunes", { rejectTitles = "MiniPlayer" })
-- list of screens to tile (use % to escape string match characters, like -)
PaperWM.window_filter:setScreens({ "Built%-in Retina Display" })
-- restart for new window filter to take effect
PaperWM:start()

Set PaperWM.center_mouse to control whether the mouse cursor is centered on the screen after switching spaces. Default is true. Example:

-- disable mouse centering when switching spaces
PaperWM.center_mouse = false

Set PaperWM.infinite_loop_window to true to enable wrapping focus at the edges of the window list. When enabled, focusing left from the leftmost window wraps to the rightmost, and focusing up from the topmost window wraps to the bottommost (and vice versa). Default is false. Example:

-- enable infinite loop scrolling for focus left/right/up/down
PaperWM.infinite_loop_window = true

Set PaperWM.window_ratios to the ratios to cycle window widths and heights through. For example:

PaperWM.window_ratios = { 1/3, 1/2, 2/3 }

Set PaperWM.default_width to set the width of newly added windows as a ratio of the screen's width (e.g., 0.5 means half the screen width):

PaperWM.default_width = 0.5

Set PaperWM.app_widths to control default window widths per app. Keys can be application names or bundle IDs, and values are width ratios (see PaperWM.default_width). app_widths overrides default_width for matching applications.

PaperWM.app_widths = {
    ["Google Chrome"] = 0.5,
    ["com.apple.Safari"] = 0.75,
}

Smooth Scrolling

https://github.com/user-attachments/assets/6f1c4659-0ca8-4ba1-a181-8c1c6987e8ef

PaperWM.spoon can scroll windows left or right by swiping fingers horizontally across the trackpad. Set the number of fingers (eg. 2, 3, or 4) and, optionally, a gain to adjust the sensitivity:

-- number of fingers to detect a horizontal swipe, set to 0 to disable (the default)
PaperWM.swipe_fingers = 0

-- increase this number to make windows move farther when swiping
-- use a negative value to reverse swipe direction
PaperWM.swipe_gain = 1.0

Inspired by ScrollDesktop.spoon

Mouse Dragging

https://github.com/user-attachments/assets/61a0afda-93e6-41b3-963c-7681a4bbe7c7

Click and drag a window with the mouse while holding the PaperWM.drag_window hotkey to slide and reposition all the windows on a space.

Click on a window with the PaperWM.lift_window hotkey held to lift it up, drag to move the window, and release the mouse to drop it in a new tiled location. This is useful for moving a window to a new screen.

-- set to a table of modifier keys to enable window dragging, default is nil
PaperWM.drag_window = { "alt", "cmd" }`

-- set to a table of modifier keys to enable window lifti
View on GitHub
GitHub Stars1.5k
CategoryDevelopment
Updated4h ago
Forks68

Languages

Lua

Security Score

100/100

Audited on Mar 31, 2026

No findings