agentlytics
Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravi…
Tiled scrollable window manager for MacOS
git clone https://github.com/mogenson/PaperWM.spoon.gitmogenson/PaperWM.spoonTiled scrollable window manager for MacOS. Inspired by PaperWM.
Spoon plugin for HammerSpoon MacOS automation app.
Clone to Hammerspoon Spoons directory: git clone https://github.com/mogenson/PaperWM.spoon ~/.hammerspoon/Spoons/PaperWM.spoon.
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".
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 >
}
})
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"},
anchor_window_left = {{"ctrl", "alt", "cmd"}, ","},
anchor_window_right = {{"ctrl", "alt", "cmd"}, "."},
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_recent_space = {{"ctrl", "alt", "cmd"}, "6"},
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"}
-- refresh/retile windows (forcedly)
refresh_windows = { { "alt", "cmd", "shift" }, "r" },
refresh_windows_forcedly = { { "alt", "cmd", "shift" }, "t" },
})
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,
}
Some applications report windows as non-maximizable even though those windows can
still be tiled correctly. Set PaperWM.allow_non_maximizable_window to opt in
specific windows:
PaperWM.allow_non_maximizable_window = function(window)
local app = window:application()
return app and app:bundleID() == "net.imput.helium"
end
Set PaperWM.preserve_app_focus to control whether to preserve the focused app when switching spaces. Default is false. For example:
-- preserve app focus when switching spaces PaperWM.preserve_app_focus = true
Set PaperWM.move_window_keep_space to stay on the current space after moving a
window to another space with move_window_1-move_window_9, instead of
switching to follow the window. Default is false. For example:
-- stay on the current space after moving a window to another space PaperWM.move_window_keep_space = true
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
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 lifting, default is nil
PaperWM.lift_window = { "alt", "cmd", "shift" }
Spin the mouse scroll wheel while holding the PaperWM.scroll_window hotkey to
slide all windows on a space left or right. Release the hotkey to stop. Change
PaperWM.scroll_gain to a positive or negative number to adjust the direction
and sensitivity.
-- set to a table of modifier keys to enable window scroling, default is nil
PaperWM.scroll_window = { "alt", "cmd" }`
-- increase move windows further when scrolling, invert to change direction
PaperWM.scroll_gain = 10.0
MacOS does not allow a window to be moved fully off-screen. Windows that would be tiled off-screen are placed in a margin on the left and right edge of the screen. They are still visible and clickable.
It's difficult to detect when a window is dragged from one space or screen to
another. Use the move_window_N commands to move windows between spaces and
screens.
Arrange screens vertically to prevent windows from bleeding into other screens. Use WarpMouse.spoon to simulate side-by-side screens.
The following spoons compliment PaperWM.spoon nicely.
-- focus adjacent window with 3 finger swipe
local actions = PaperWM.actions.actions()
local current_id, threshold
Swipe = hs.loadSpoon("Swipe")
Swipe:start(3, function(direction, distance, id)
if id == current_id then
if distance > threshold then
threshold = math.huge -- trigger once per swipe
-- use "natural" scrolling
if direction == "left" then
actions.focus_right()
elseif direction == "right" then
actions.focus_left()
elseif direction == "up" then
actions.focus_down()
elseif direction == "down" then
actions.focus_up()
end
end
else
current_id = id
threshold = 0.2 -- swipe distance > 20% of trackpad size
end
end)
Contributions are welcome! Here are a few preferences:
PascalCase (eg. PaperWM)snake_case (eg. local focused_window)camelCase (eg. function windowEventHandler())<const> where possiblelocal Watcher <const> = hs.uielement.watcher)Code format checking and linting is provided by
lua-language-server for commits
and pull requests. Run lua-language-server --check . locally before commiting.
Busted is used for unit testing. Run
busted from the repo root to run tests locally.
more like this
Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravi…
Free open-source Cloudflare R2 desktop client and S3 GUI for macOS, Windows, and Linux. Manage Cloudflare R2, AWS S3, M…
SCYTHE is a lightweight, C#-based game engine focused on modifiability and rapid iteration using Raylib.
search projects, people, and tags