如何在 pyqt 中自定义无边框窗口 前言 用 Python 的 ctypes
和 pywin32
来解决无边框窗口的问题(最新的代码里使用 xcffib
和 pyobjc
实现了 Linux 和 macOS 系统的无边框窗口)。先来看看无边框窗口的效果:
需要解决的问题 在pyqt中只要 self.setWindowFlags(Qt.FramelessWindowHint)
就可以实现边框的去除,但是没了边框会带来一系列问题:
窗口无法移动
窗口无法拉伸
窗口动画消失
窗口阴影消失
下面我们会一个个地解决上述问题,并且给出 Windows 的 Aero 和 Acrylic 窗口特效的实现方法。
自定义标题栏 为了还原窗口的移动、最大化、最小化和关闭功能,我们需要实现一个标题栏 WindowsTitleBar
。注意下面只会给出关键代码,完整代码请移步 PyQt-Frameless-Window。
窗口移动 要实现窗口移动,我们需要重写标题栏的 mousePressEvent()
,并调用 win32api.SendMessage()
和 win32gui.ReleaseCapture()
,将鼠标按下并拖动的消息 win32con.SC_MOVE + win32con.HTCAPTION
发送给 Windows,让它知道该拖动窗口了(如果运行代码时报错 “ImportError: DLL load failed while importing win32api”,解决方案可以参见 《如何在 python 中解决 ImportError: DLL load failed while importing win32api》)。下面是实现代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 ```python class WindowsTitleBar (TitleBarBase ): """ Title bar for Windows system """ def mousePressEvent (self, event ): """ Move the window """ if not self ._isDragRegion(event.pos()): return ReleaseCapture() SendMessage(self .window().winId(), win32con.WM_SYSCOMMAND, win32con.SC_MOVE + win32con.HTCAPTION, 0 ) event.ignore()
窗口最大化、最小化、还原和关闭 当我们双击标题栏时,窗口应该由正常大小变为最大化状态,或者由最大化状态还原为正常大小,为了实现这个功能,我们需要重写标题栏的 mouseDoubleClickEvent()
。而普通的最大化、最小化和关闭功能只需将按钮的点击信号连接到槽函数即可,下面是具体代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class TitleBarBase (QWidget ): """ Title bar base class """ def __init__ (self, parent ): super ().__init__(parent) self .minBtn.clicked.connect(self .window().showMinimized) self .maxBtn.clicked.connect(self .__toggleMaxState) self .closeBtn.clicked.connect(self .window().close) self .window().installEventFilter(self ) def eventFilter (self, obj, e ): if obj is self .window(): if e.type () == QEvent.WindowStateChange: self .maxBtn.setMaxState(self .window().isMaximized()) return False return super ().eventFilter(obj, e) def mouseDoubleClickEvent (self, event ): """ Toggles the maximization state of the window """ if event.button() != Qt.LeftButton: return self .__toggleMaxState() def __toggleMaxState (self ): """ Toggles the maximization state of the window and change icon """ if self .window().isMaximized(): self .window().showNormal() else : self .window().showMaximized()
WindowEffect 类 为了给无边框窗口添加阴影,并设置 Aero 和 Acrylic 窗口特效,我们需要实现 WindowEffect
类,它还将提供还原窗口动画的功能。
窗口阴影 要给窗口添加上一层阴影有许多方法,比如:
在当前窗口外再嵌套一层窗口,并通过 self.setGraphicsEffect()
给当前窗口添加上 QGraphicsDropShadowEffect
,优点是我们可以任意调节阴影的半径、偏移量和颜色;
重写顶层窗口的 paintEvent()
,手动画出一层阴影,不过这种方法画出来阴影在拐角处看起来会有些不自然;
我们不会使用这两种方法,而是通过调用 ~ctypes.WinDLL('dwmapi')
中的接口函数来还原原生的 DWM 窗口阴影。
接口函数 为了实现 DWM
环绕阴影,需要调用 dwmapi
中的两个函数:
HRESULT DwmSetWindowAttribute (HWND hwnd, DWORD dwAttribute, LPCVOID pvAttribute, DWORD cbAttribute)
,用来设置窗口的桌面窗口管理器(DWM)非客户端呈现属性的值,可以参见文档 DwmSetWindowAttribute函数;
HRESULT DwmExtendFrameIntoClientArea (HWND hWnd, const MARGINS *pMarInset)
,用来将窗口框架扩展到工作区,参见文档 DwmExtendFrameIntoClientArea函数 和 DWM模糊概述;
在调用这两个函数之前,我们需要先在 WindowEffect
的构造函数中声明一下他们的函数原型
1 2 3 4 5 6 7 self .dwmapi = WinDLL("dwmapi" )self .DwmExtendFrameIntoClientArea = self .dwmapi.DwmExtendFrameIntoClientAreaself .DwmSetWindowAttribute = self .dwmapi.DwmSetWindowAttributeself .DwmExtendFrameIntoClientArea.restype = LONGself .DwmSetWindowAttribute.restype = LONGself .DwmSetWindowAttribute.argtypes = [c_int, DWORD, LPCVOID, DWORD]self .DwmExtendFrameIntoClientArea.argtypes = [c_int, POINTER(MARGINS)]
结构体和枚举类 从MSDN文档可以得知,传入 DwmExtendFrameIntoClientArea()
的第二个参数 pMarInset
是一个结构体 MARGIN
的指针,所以我们下面定义一下 MARGIN
,同时定义一些要用到的枚举类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 from ctypes import Structure, c_intfrom enum import Enumclass DWMNCRENDERINGPOLICY (Enum ): DWMNCRP_USEWINDOWSTYLE = 0 DWMNCRP_DISABLED = 1 DWMNCRP_ENABLED = 2 DWMNCRP_LAS = 3 class DWMWINDOWATTRIBUTE (Enum ): DWMWA_NCRENDERING_ENABLED = 1 DWMWA_NCRENDERING_POLICY = 2 DWMWA_TRANSITIONS_FORCEDISABLED = 3 DWMWA_ALLOW_NCPAINT = 4 DWMWA_CAPTION_BUTTON_BOUNDS = 5 DWMWA_NONCLIENT_RTL_LAYOUT = 6 DWMWA_FORCE_ICONIC_REPRESENTATION = 7 DWMWA_FLIP3D_POLICY = 8 DWMWA_EXTENDED_FRAME_BOUNDS = 9 DWMWA_HAS_ICONIC_BITMAP = 10 DWMWA_DISALLOW_PEEK = 11 DWMWA_EXCLUDED_FROM_PEEK = 12 DWMWA_CLOAK = 13 DWMWA_CLOAKED = 14 DWMWA_FREEZE_REPRESENTATION = 25 DWMWA_LAST = 16 class MARGINS (Structure ): _fields_ = [ ("cxLeftWidth" , c_int), ("cxRightWidth" , c_int), ("cyTopHeight" , c_int), ("cyBottomHeight" , c_int), ]
还原阴影 准备工作完成,我们来看一下 WindowEffect
中拿来给无边框窗口添加环绕阴影的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ```python def addShadowEffect (self, hWnd ): """ 给窗口添加阴影 Parameter ---------- hWnd: int or `sip.voidptr` 窗口句柄 """ hWnd = int (hWnd) self .DwmSetWindowAttribute( hWnd, DWMWINDOWATTRIBUTE.DWMWA_NCRENDERING_POLICY.value, byref(c_int(DWMNCRENDERINGPOLICY.DWMNCRP_ENABLED.value)), 4 , ) margins = MARGINS(-1 , -1 , -1 , -1 ) self .DwmExtendFrameIntoClientArea(hWnd, byref(margins))
这是这篇博客中首次出现窗口句柄 hWnd
,我们后面还会再用到它。简单理解,一个 hWnd
就是一个 顶层窗口 的 ID
,具体介绍参见 窗口句柄。在 pyqt 中,通过 self.winId()
可以获得 sip.voidptr
类型的 hWnd
,可以通过 int(self.windId())
将其转换为整数。从上面的代码也可以看出, hWnd
是很重要的,很多接口函数都将 hWnd
作为第一个参数。
窗口动画 要想还原最大化和最小化时的窗口动画,只需通过 win32gui.SetWindowLong()
重新设置一下窗口样式即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def addWindowAnimation (self, hWnd ): """ 还原窗口动画效果 Parameters ---------- hWnd : int or `sip.voidptr` 窗口句柄 """ hWnd = int (hWnd) style = win32gui.GetWindowLong(hWnd, win32con.GWL_STYLE) win32gui.SetWindowLong( hWnd, win32con.GWL_STYLE, style | win32con.WS_MAXIMIZEBOX | win32con.WS_CAPTION | win32con.CS_DBLCLKS | win32con.WS_THICKFRAME, )
Aero 和 Acrylic 为了吸引眼球,Win7 引入了Aero,Win10 引入了 Acrylic 亚克力效果,要想给我们的窗口也添加上这两种效果,需要用到 ~ctypes.WinDLL('user32')
的一个接口函数 SetWindowCompositionAttribute()
。和添加窗口阴影相似,在调用这个函数之前,我们需要一些准备工作。
接口函数 我们先在 WindowEffect
的构造函数中声明一下函数原型并初始化一些要作为参数的结构体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 self .user32 = WinDLL("user32" )self .SetWindowCompositionAttribute = self .user32.SetWindowCompositionAttributeself .SetWindowCompositionAttribute.restype = c_boolself .SetWindowCompositionAttribute.argtypes = [ c_int, POINTER(WINDOWCOMPOSITIONATTRIBDATA), ] self .accentPolicy = ACCENT_POLICY()self .winCompAttrData = WINDOWCOMPOSITIONATTRIBDATA()self .winCompAttrData.Attribute = WINDOWCOMPOSITIONATTRIB.WCA_ACCENT_POLICY.valueself .winCompAttrData.SizeOfData = sizeof(self .accentPolicy)self .winCompAttrData.Data = pointer(self .accentPolicy)
结构体 下面是上述代码用到的结构体和枚举类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 from ctypes import POINTER, Structure, c_intfrom ctypes.wintypes import DWORD, HWND, ULONG, POINT, RECT, UINTfrom enum import Enumclass WINDOWCOMPOSITIONATTRIB (Enum ): WCA_UNDEFINED = 0 WCA_NCRENDERING_ENABLED = 1 WCA_NCRENDERING_POLICY = 2 WCA_TRANSITIONS_FORCEDISABLED = 3 WCA_ALLOW_NCPAINT = 4 WCA_CAPTION_BUTTON_BOUNDS = 5 WCA_NONCLIENT_RTL_LAYOUT = 6 WCA_FORCE_ICONIC_REPRESENTATION = 7 WCA_EXTENDED_FRAME_BOUNDS = 8 WCA_HAS_ICONIC_BITMAP = 9 WCA_THEME_ATTRIBUTES = 10 WCA_NCRENDERING_EXILED = 11 WCA_NCADORNMENTINFO = 12 WCA_EXCLUDED_FROM_LIVEPREVIEW = 13 WCA_VIDEO_OVERLAY_ACTIVE = 14 WCA_FORCE_ACTIVEWINDOW_APPEARANCE = 15 WCA_DISALLOW_PEEK = 16 WCA_CLOAK = 17 WCA_CLOAKED = 18 WCA_ACCENT_POLICY = 19 WCA_FREEZE_REPRESENTATION = 20 WCA_EVER_UNCLOAKED = 21 WCA_VISUAL_OWNER = 22 WCA_LAST = 23 class ACCENT_STATE (Enum ): """ 客户区状态枚举类 """ ACCENT_DISABLED = 0 ACCENT_ENABLE_GRADIENT = 1 ACCENT_ENABLE_TRANSPARENTGRADIENT = 2 ACCENT_ENABLE_BLURBEHIND = 3 ACCENT_ENABLE_ACRYLICBLURBEHIND = 4 ACCENT_INVALID_STATE = 5 class ACCENT_POLICY (Structure ): """ 设置客户区的具体属性 """ _fields_ = [ ("AccentState" , DWORD), ("AccentFlags" , DWORD), ("GradientColor" , DWORD), ("AnimationId" , DWORD), ] class WINDOWCOMPOSITIONATTRIBDATA (Structure ): _fields_ = [ ("Attribute" , DWORD), ("Data" , POINTER(ACCENT_POLICY)), ("SizeOfData" , ULONG), ]
添加窗口特效 上述结构体中 AccentPolicy.AccentState
可以控制着窗口的多种效果,通过改变它的值,我们可以实现 Aero、Acrylic 等多种效果。对于这些效果的研究,可以参见 《使用 SetWindowCompositionAttribute 来控制程序的窗口边框和背景》,里面介绍的十分详尽。下面我们来看看 WindowEffect
中给窗口添加 Acrylic 和 Aero 效果的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 def setAcrylicEffect (self, hWnd, gradientColor: str = "F2F2F230" , isEnableShadow: bool = True , animationId: int = 0 ): """ 给窗口开启Win10的亚克力效果 Parameter ---------- hWnd: int or `sip.voidptr` 窗口句柄 gradientColor: str 十六进制亚克力混合色,对应 RGBA 四个分量 isEnableShadow: bool 控制是否启用窗口阴影 animationId: int 控制磨砂动画 """ gradientColor = ( gradientColor[6 :] + gradientColor[4 :6 ] + gradientColor[2 :4 ] + gradientColor[:2 ] ) gradientColor = DWORD(int (gradientColor, base=16 )) animationId = DWORD(animationId) accentFlags = DWORD(0x20 | 0x40 | 0x80 | 0x100 ) if isEnableShadow else DWORD(0 ) self .accentPolicy.AccentState = ACCENT_STATE.ACCENT_ENABLE_ACRYLICBLURBEHIND.value self .accentPolicy.GradientColor = gradientColor self .accentPolicy.AccentFlags = accentFlags self .accentPolicy.AnimationId = animationId self .SetWindowCompositionAttribute(int (hWnd), pointer(self .winCompAttrData)) def setAeroEffect (self, hWnd ): """ 给窗口开启Aero效果 Parameter ---------- hWnd: int or `sip.voidptr` 窗口句柄 """ self .accentPolicy.AccentState = ACCENT_STATE.ACCENT_ENABLE_BLURBEHIND.value self .SetWindowCompositionAttribute(int (hWnd), pointer(self .winCompAttrData))
虽然亚克力的视觉效果很不错,但是在拖动窗口时会出现 窗口卡顿 问题,一种在 本机 的解决方案是去掉 高级系统设置 -> 性能 -> 拖动时显示窗口内容 复选框的 √ :
WindowsFramelessWindow 类 最后我们还剩一个窗口拉伸问题,为了解决这个问题,我们需要定义一个无边框窗口 WindowsFramelessWindow
类。在构造函数里面我们利用 WindowEffect
类给无边框窗口加上了窗口阴影和窗口动画,还有一点需要强调的是,我们不是简单地用 self.setWindowFlags(Qt.FramelessWindowHint)
来取消边框,而要或上原本的窗口标志,目的是解决点击任务栏图标窗口无法最小化或者还原的问题。下面是无边框窗口的构造函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class WindowsFramelessWindow (QWidget ): """ Frameless window for Windows system """ BORDER_WIDTH = 5 def __init__ (self, parent=None ): super ().__init__(parent=parent) self .windowEffect = WindowsWindowEffect() self .titleBar = TitleBar(self ) self .setWindowFlags(self .windowFlags() | Qt.FramelessWindowHint) self .windowEffect.addWindowAnimation(self .winId()) if not isinstance (self , AcrylicWindow): self .windowEffect.addShadowEffect(self .winId()) self .windowHandle().screenChanged.connect(self .__onScreenChanged) self .resize(500 , 500 ) self .titleBar.raise_()
窗口拉伸 为了实现窗口拉伸,我们需要在 nativeEvent()
中处理 WM_NCHITTEST
消息,来告诉 Windows 光标已经到了窗口的边沿,该改变光标的样式并允许我们拉伸窗口了。实现方式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 def nativeEvent (self, eventType, message ): """ 处理 Windows 消息 """ msg = MSG.from_address(message.__int__()) if msg.message == win32con.WM_NCHITTEST: pos = QCursor.pos() xPos = pos.x() - self .x() yPos = pos.y() - self .y() w, h = self .width(), self .height() lx = xPos < self .BORDER_WIDTH rx = xPos > w - self .BORDER_WIDTH ty = yPos < self .BORDER_WIDTH by = yPos > h - self .BORDER_WIDTH if lx and ty: return True , win32con.HTTOPLEFT elif rx and by: return True , win32con.HTBOTTOMRIGHT elif rx and ty: return True , win32con.HTTOPRIGHT elif lx and by: return True , win32con.HTBOTTOMLEFT elif ty: return True , win32con.HTTOP elif by: return True , win32con.HTBOTTOM elif lx: return True , win32con.HTLEFT elif rx: return True , win32con.HTRIGHT return QWidget.nativeEvent(self , eventType, message)
窗口最大化 至此,我们已经解决了罗列出来的所有问题,但是新的问题也接踵而来,那就是
如果还原窗口动画,就会导致窗口最大化时尺寸超过正确的显示器尺寸,甚至最大化之后又会有新的标题栏跑出来。
比如在分辨率为 1920×1080 的显示器最大化窗口时,窗口的边框坐标 (left, top, right, bottom)
实际是 (-9, -9, 1929, 1089)
而不是 (0, 0, 1920, 1080)
。要解决这个问题必须在 nativeEvent
中处理另外一个消息:WM_NCCALCSIZE
。
结构体 在处理这两个消息的时候,我们会调用 win32api
和 win32gui
中的一些接口函数,所以需要先定义一些结构体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class PWINDOWPOS (Structure ): _fields_ = [ ('hWnd' , HWND), ('hwndInsertAfter' , HWND), ('x' , c_int), ('y' , c_int), ('cx' , c_int), ('cy' , c_int), ('flags' , UINT) ] class NCCALCSIZE_PARAMS (Structure ): _fields_ = [ ('rgrc' , RECT*3 ), ('lppos' , POINTER(PWINDOWPOS)) ] LPNCCALCSIZE_PARAMS = POINTER(NCCALCSIZE_PARAMS)
处理消息 当我们收到 WM_NCCALCSIZE
消息且窗口最大化或全屏时,需要对窗口的边框坐标做出调整,把多出来的部分给切掉。仅仅这么做还不够,有时候用户可能把自动隐藏任务栏功能开了起来,像谷歌浏览器最大化后,底部(假设任务栏在底部)可以看到两个像素的任务栏边框,鼠标移动到底部任务栏就会弹出。对于我们实现的这个无边框窗口,由于调整后的窗口大小正好等于显示器的分辨率,鼠标移动到底部不会有任何反应。解决方案就是把窗口底部减去两个像素的高度,这样就能看到任务栏了。下面是处理这个消息的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 def nativeEvent (self, eventType, message ): """ 处理windows消息 """ msg = MSG.from_address(message.__int__()) if msg.message == win32con.WM_NCCALCSIZE: if msg.wParam: rect = cast(msg.lParam, LPNCCALCSIZE_PARAMS).contents.rgrc[0 ] else : rect = cast(msg.lParam, LPRECT).contents isMax = win_utils.isMaximized(msg.hWnd) isFull = win_utils.isFullScreen(msg.hWnd) if isMax and not isFull: thickness = win_utils.getResizeBorderThickness(msg.hWnd) rect.top += thickness rect.left += thickness rect.right -= thickness rect.bottom -= thickness if (isMax or isFull) and Taskbar.isAutoHide(): position = Taskbar.getPosition(msg.hWnd) if position == Taskbar.LEFT: rect.top += Taskbar.AUTO_HIDE_THICKNESS elif position == Taskbar.BOTTOM: rect.bottom -= Taskbar.AUTO_HIDE_THICKNESS elif position == Taskbar.LEFT: rect.left += Taskbar.AUTO_HIDE_THICKNESS elif position == Taskbar.RIGHT: rect.right -= Taskbar.AUTO_HIDE_THICKNESS result = 0 if not msg.wParam else win32con.WVR_REDRAW return True , result return QWidget.nativeEvent(self , eventType, message)
写在最后 本文参考zhiyiyo大佬