下面先给出一个 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)或桌面环境自带的平铺插件
网友回复


