+
27
-

回答

下面先给出一个 Windows 版的最小可用示例:拖动任意窗口松手后,会自动吸附到预设的区域(类似 PowerToys FancyZones 的核心逻辑)。

思路

预设若干“区域”(矩形)

监听系统窗口拖动结束事件(Move/Size End)

判断所拖窗口的中心点/重叠面积落在哪个区域

调用 SetWindowPos 把窗口移动/缩放到该区域

Windows 最小示例(纯 Python,无第三方库)

功能:默认把主屏工作区分为 2×2 四个分屏。你拖动窗口并松手,它会吸附到对应区域。

运行环境:Windows 10/11,Python 3.x

权限建议:若想捕获/控制提权窗口,最好以管理员权限运行

# -*- coding: utf-8 -*-
# Windows: 拖动窗口松手后自动吸附到预设区域(2x2 网格)
import ctypes
from ctypes import wintypes

user32 = ctypes.windll.user32

# 常量
EVENT_SYSTEM_MOVESIZESTART = 0x000A
EVENT_SYSTEM_MOVESIZEEND   = 0x000B
WINEVENT_OUTOFCONTEXT      = 0x0000
WINEVENT_SKIPOWNPROCESS    = 0x0002
OBJID_WINDOW               = 0
GA_ROOT                    = 2

SWP_NOZORDER   = 0x0004
SWP_NOACTIVATE = 0x0010
SWP_SHOWWINDOW = 0x0040

SPI_GETWORKAREA = 0x0030
SW_RESTORE = 9

# 结构体
class RECT(ctypes.Structure):
    _fields_ = [
        ("left",   wintypes.LONG),
        ("top",    wintypes.LONG),
        ("right",  wintypes.LONG),
        ("bottom", wintypes.LONG),
    ]

# 函数声明(可选但更稳)
user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(RECT)]
user32.GetWindowRect.restype  = wintypes.BOOL

user32.SetWindowPos.argtypes = [wintypes.HWND, wintypes.HWND,
                                ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
                                wintypes.UINT]
user32.SetWindowPos.restype  = wintypes.BOOL

user32.SystemParametersInfoW.argtypes = [wintypes.UINT, wintypes.UINT,
                                         ctypes.c_void_p, wintypes.UINT]
user32.SystemParametersInfoW.restype  = wintypes.BOOL

user32.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
user32.GetAncestor.restype  = wintypes.HWND

user32.IsWindowVisible.argtypes = [wintypes.HWND]
user32.IsWindowVisible.restype  = wintypes.BOOL

user32.IsIconic.argtypes = [wintypes.HWND]
user32.IsIconic.restype  = wintypes.BOOL

user32.IsZoomed.argtypes = [wintypes.HWND]
user32.IsZoomed.restype  = wintypes.BOOL

user32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
user32.ShowWindow.restype  = wintypes.BOOL

# 工具函数
def get_work_area():
    r = RECT()
    user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, ctypes.byref(r), 0)
    return r

def build_grid_zones(wa_rect, cols=2, rows=2, gap=12):
    zones = []
    w = wa_rect.right - wa_rect.left
    h = wa_rect.bottom - wa_rect.top
    cell_w = w / cols
    cell_h = h / rows
    for rr in range(rows):
        for cc in range(cols):
            left   = int(wa_rect.left + cc * cell_w + gap / 2)
            right  = int(wa_rect.left + (cc + 1) * cell_w - gap / 2)
            top    = int(wa_rect.top  + rr * cell_h + gap / 2)
            bottom = int(wa_rect.top  + (rr + 1) * cell_h - gap / 2)
            zones.append((left, top, right, bottom))
    return zones

def contains(rect, x, y):
    l, t, r, b = rect
    return l <= x <= r and t <= y <= b

def intersect_area(a, b):
    l1, t1, r1, b1 = a
    l2, t2, r2, b2 = b
    l = max(l1, l2)
    t = max(t1, t2)
    r = min(r1, r2)
    b = min(b1, b2)
    if r <= l or b <= t:
        return 0
    return (r - l) * (b - t)

def get_window_rect(hwnd):
    r = RECT()
    if not user32.GetWindowRect(hwnd, ctypes.byref(r)):
        return None
    return (r.left, r.top, r.right, r.bottom)

def move_window_to(hwnd, rect):
    l, t, r, b = rect
    w = r - l
    h = b - t
    user32.SetWindowPos(hwnd, 0, l, t, w, h, SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW)

def choose_zone_for_window(wrect, zones):
    # 先看窗口中心点在哪个区域,不在则找重叠面积最大的区域
    cx = (wrect[0] + wrect[2]) // 2
    cy = (wrect[1] + wrect[3]) // 2
    for z in zones:
        if contains(z, cx, cy):
            return z
    best = None
    best_area = 0
    for z in zones:
        area = intersect_area(z, wrect)
        if area > best_area:
            best_area = area
            best = z
    return best

# 全局区
GLOBAL_ZONES = []
WinEventProcType = ctypes.WINFUNCTYPE(
    None,          # 返回值
    wintypes.HANDLE,  # hWinEventHook
    wintypes.DWORD,   # event
    wintypes.HWND,    # hwnd
    wintypes.LONG,    # idObject
    wintypes.LONG,    # idChild
    wintypes.DWORD,   # idEventThread
    wintypes.DWORD    # dwmsEventTime
)

def on_win_event(hWinEventHook, event, hwnd, idObject, idChild, dwEventThread, dwmsEventTime):
    # 仅在“拖拽结束”时处理
    if event != EVENT_SYSTEM_MOVESIZEEND:
        return
    if idObject != OBJID_WINDOW:
        return
    if not user32.IsWindowVisible(hwnd) or user32.IsIconic(hwnd):
        return

    # 获取顶层窗口句柄
    root = user32.GetAncestor(hwnd, GA_ROOT)
    if root:
        hwnd = root

    wrect = get_window_rect(hwnd)
    if not wrect:
        return

    zone = choose_zone_for_window(wrect, GLOBAL_ZONES)
    if not zone:
        return

    # 若窗口最大化,先还原
    if user32.IsZoomed(hwnd):
        user32.ShowWindow(hwnd, SW_RESTORE)

    move_window_to(hwnd, zone)

def main():
    global GLOBAL_ZONES, win_event_cb, hook
    wa = get_work_area()
    GLOBAL_ZONES = build_grid_zones(wa, cols=2, rows=2, gap=12)

    print("已启用 2x2 分屏区域:")
    for i, z in enumerate(GLOBAL_ZONES):
        print(f"  区域 {i}: {z}")
    print("拖动任意窗口并松手,窗口将吸附到对应区域(Ctrl+C 退出)")

    win_event_cb = WinEventProcType(on_win_event)  # 保持引用,避免回收
    hook = user32.SetWinEventHook(
        EVENT_SYSTEM_MOVESIZESTART, EVENT_SYSTEM_MOVESIZEEND,
        0, win_event_cb, 0, 0,
        WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS
    )
    if not hook:
        print("SetWinEventHook 失败,请以管理员身份运行再试。")
        return

    msg = wintypes.MSG()
    try:
        while True:
            r = user32.GetMessageW(ctypes.byref(msg), 0, 0, 0)
            if r == 0 or r == -1:
                break
            user32.TranslateMessage(ctypes.byref(msg))
            user32.DispatchMessageW(ctypes.byref(msg))
    except KeyboardInterrupt:
        pass
    finally:
        user32.UnhookWinEvent(hook)

if __name__ == "__main__":
    main()

怎么自定义“分屏区域”

修改 build_grid_zones(wa, cols=..., rows=..., gap=...) 即可变为 1x3、3x3 等网格

或者手动写死区域列表,例如:

把左侧 40% 作为区域 A,右侧 60% 再分上下两个区域

wa = get_work_area()
W = wa.right - wa.left
H = wa.bottom - wa.top
GLOBAL_ZONES = [
(wa.left, wa.top, wa.left + int(W*0.40), wa.top + H),                         # 左 40%
(wa.left + int(W*0.40) + 8, wa.top, wa.right, wa.top + H//2 - 4),             # 右上
(wa.left + int(W*0.40) + 8, wa.top + H//2 + 4, wa.right, wa.bottom)           # 右下
]

可选增强

高亮覆盖层:用 PySide6/PyQt 绘制一个透明、置顶、穿透鼠标的 overlay,在 EVENT_SYSTEM_MOVESIZESTART 时显示区域轮廓,在 END 时隐藏

多显示器支持:使用 EnumDisplayMonitors + GetMonitorInfo,为每个显示器构建独立区域;在拖动时根据窗口中心点所属显示器选择对应区域集合

快捷键吸附:注册全局热键,把活动窗口一键吸附到某区域(更稳、更简单)

开机自启/托盘驻留:做成小工具

跨平台提示

macOS

需要“辅助功能”权限

方案:PyObjC + Accessibility API(AXObserver 监听窗口移动),或用现成的 yabai/Rectangle;Python 调用 yabai CLI 可直接按网格设置窗口位置

Linux

X11 下可用 python-xlib + EWMH/wmctrl 控制窗口

Wayland 下通常受限,建议使用平铺式 WM(i3/sway)或桌面环境自带的平铺插件

网友回复

我知道答案,我要回答