@GitHub_Daily: Mac 的窗口管理,开到七八个窗口就开始乱,找东西全靠调度中心里翻。 PaperWM spoon 换了个思路,窗口不再互相叠着,全部平铺在一条能左右滚动的长带上。 新窗口只往旁边排,不遮挡别人,快捷键左右滚动切换,像翻一卷胶片。 GitH…

X AI KOLs Timeline 工具

摘要

PaperWM spoon 是一个 Hammerspoon 插件,为 Mac 提供平铺式窗口管理,窗口排列在可滚动的长带上,通过快捷键操作,旨在改善多窗口工作流。

Mac 的窗口管理,开到七八个窗口就开始乱,找东西全靠调度中心里翻。 PaperWM spoon 换了个思路,窗口不再互相叠着,全部平铺在一条能左右滚动的长带上。 新窗口只往旁边排,不遮挡别人,快捷键左右滚动切换,像翻一卷胶片。 GitHub:http://github.com/mogenson/PaperWM.spoon… 它是 Mac 自动化工具 Hammerspoon 的插件,快捷键全部自己说了算。 窗口宽度按档位循环调,两个窗口一键分屏,多显示器、多桌面都支持。 这套滚动平铺的玩法在 Linux 桌面圈流行了很多年,搬到 Mac 上来还挺新鲜。 习惯键盘流、嫌 Mac 原生窗口管理糙的朋友,可以拿它改造一下工作区。
查看原文
查看缓存全文

缓存时间: 2026/08/19 22:47

Mac 的窗口管理,开到七八个窗口就开始乱,找东西全靠调度中心里翻。

PaperWM spoon 换了个思路,窗口不再互相叠着,全部平铺在一条能左右滚动的长带上。

新窗口只往旁边排,不遮挡别人,快捷键左右滚动切换,像翻一卷胶片。

GitHub:http://github.com/mogenson/PaperWM.spoon…

它是 Mac 自动化工具 Hammerspoon 的插件,快捷键全部自己说了算。

窗口宽度按档位循环调,两个窗口一键分屏,多显示器、多桌面都支持。

这套滚动平铺的玩法在 Linux 桌面圈流行了很多年,搬到 Mac 上来还挺新鲜。

习惯键盘流、嫌 Mac 原生窗口管理糙的朋友,可以拿它改造一下工作区。


mogenson/PaperWM.spoon

Source: https://github.com/mogenson/PaperWM.spoon

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”.

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"},
    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

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 lifting, default is nil
PaperWM.lift_window = { "alt", "cmd", "shift" }

Mouse Scrolling

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 scrolling, default is nil
PaperWM.scroll_window = { "alt", "cmd" }`

-- increase move windows further when scrolling, invert to change direction
PaperWM.scroll_gain = 10.0

Limitations

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.

Add-ons

The following spoons compliment PaperWM.spoon nicely.

  • ActiveSpace.spoon Show active and layout of Mission Control spaces in the menu bar.
  • WarpMouse.spoon Move mouse cursor between screen edges to simulate side-by-side screens.
  • Swipe.spoon Perform actions when trackpad swipe gestures are recognized. Here’s an example config to change PaperWM.spoon focused window:
-- 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)
  • FocusMode.spoon Helps you stay in flow by dimming everything except what you’re working on.

Contributing

Contributions are welcome! Here are a few preferences:

  • Global variables are PascalCase (eg. PaperWM)
  • Local variables are snake_case (eg. local focused_window)
  • Function names are camelCase (eg. function windowEventHandler())
  • Use <const> where possible
  • Create a local copy when deeply nested members are used often (eg. local 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 committing.

Busted is used for unit testing. Run busted from the repo root to run tests locally.

相似文章

@wsl8297: 在 Mac 上干活,一开就是十几个窗口。要在一堆窗口里把目标翻出来,经常比做事本身还费劲。 最近刷到 Paneru 这款开源窗口管理器,思路很直:用横向“滚动条”来管窗口,窗口切换一下变得有序、好找。 它有点像 Linux 上的 Niri…

X AI KOLs Timeline

Paneru 是一款针对 macOS 的开源平铺式窗口管理器,采用独特的水平滚动条布局,旨在解决多窗口管理混乱的问题,灵感来源于 Linux 上的 Niri。

@wsl8297: 逛 GitHub 时挖到一个狠角色:niri。它直接绕开传统桌面“窗口一多就被挤变形”的老毛病,把窗口管理方式彻底重做了一遍。 niri 采用可滚动的平铺思路:窗口按列铺在一条“无限延伸”的水平条带上。你开新窗口,不会强行压缩现有窗口;多…

X AI KOLs Timeline

文章介绍了开源项目 niri,这是一款基于 Wayland 的新型桌面合成器,通过可滚动平铺布局避免传统窗口拥挤问题,并支持动态工作区、多显示器独立管理及自定义着色器动画等特性。

@I00B6: 别急着给 Mac 软件续费/购买各种APP 最近发现一个开源菜单栏工具,几乎把一堆常用付费工具的功能全做进去了 音量控制、系统监控、窗口切换、文件暂存、应用卸载、防休眠…… 一个 App 全包,而且免费、开源、无广告、本地运行 开源生态现…

X AI KOLs Timeline

介绍一款开源Mac菜单栏工具Vorssaint,它整合了音量控制、系统监控、窗口切换等多项常见付费功能,免费无广告且本地运行。