• 如何在pyqt中实现窗口磨砂效果
  • 如何在pyqt中实现win10亚克力效果
  • 如何在pyqt中通过调用SetWindowCompositionAttribute实现Win10亚克力效果
  • 如何在pyqt中在实现无边框窗口的同时保留Windows窗口动画效果(一)
  • 如何在pyqt中给无边框窗口添加DWM环绕阴影
  • 如何在pyqt中在实现无边框窗口的同时保留Windows窗口动画效果(二)
  • 里面有几篇博客用了 C++ 的 dll,虽然也给出了不用 dll 的实现方法,但还是觉得先前写的自定义无边框窗口的解决方案有些混乱,所以这次就总结在这一篇博客里面,并且只用 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》 )。下面是实现代码:

    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()。而普通的最大化、最小化和关闭功能只需将按钮的点击信号连接到槽函数即可,下面是具体代码:

    class TitleBarBase(QWidget):
        """ Title bar base class """
        def __init__(self, parent):
            super().__init__(parent)
            # 省略部分代码
            # connect signal to slot
            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的构造函数中声明一下他们的函数原型

    self.dwmapi = WinDLL("dwmapi")
    self.DwmExtendFrameIntoClientArea = self.dwmapi.DwmExtendFrameIntoClientArea
    self.DwmSetWindowAttribute = self.dwmapi.DwmSetWindowAttribute
    self.DwmExtendFrameIntoClientArea.restype = LONG
    self.DwmSetWindowAttribute.restype = LONG
    self.DwmSetWindowAttribute.argtypes = [c_int, DWORD, LPCVOID, DWORD]
    self.DwmExtendFrameIntoClientArea.argtypes = [c_int, POINTER(MARGINS)]
    

    结构体和枚举类

    从MSDN文档可以得知,传入 DwmExtendFrameIntoClientArea() 的第二个参数 pMarInset 是一个结构体 MARGIN 的指针,所以我们下面定义一下 MARGIN ,同时定义一些要用到的枚举类:

    # coding: utf-8
    from ctypes import Structure, c_int
    from enum import Enum
    class 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 中拿来给无边框窗口添加环绕阴影的函数:

    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)),
        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() 重新设置一下窗口样式即可:

    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 的构造函数中声明一下函数原型并初始化一些要作为参数的结构体:

    # 调用api
    self.user32 = WinDLL("user32")
    self.SetWindowCompositionAttribute = self.user32.SetWindowCompositionAttribute
    self.SetWindowCompositionAttribute.restype = c_bool
    self.SetWindowCompositionAttribute.argtypes = [
        c_int,
        POINTER(WINDOWCOMPOSITIONATTRIBDATA),
    # 初始化结构体
    self.accentPolicy = ACCENT_POLICY()
    self.winCompAttrData = WINDOWCOMPOSITIONATTRIBDATA()
    self.winCompAttrData.Attribute = WINDOWCOMPOSITIONATTRIB.WCA_ACCENT_POLICY.value
    self.winCompAttrData.SizeOfData = sizeof(self.accentPolicy)
    self.winCompAttrData.Data = pointer(self.accentPolicy)
    

    下面是上述代码用到的结构体和枚举类:

    # coding:utf-8
    from ctypes import POINTER, Structure, c_int
    from ctypes.wintypes import DWORD, HWND, ULONG, POINT, RECT, UINT
    from enum import Enum
    class 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          # Aero效果
        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),
            # POINTER()接收任何ctypes类型,并返回一个指针类型
            ("Data",        POINTER(ACCENT_POLICY)),
            ("SizeOfData",  ULONG),
    

    添加窗口特效

    上述结构体中 AccentPolicy.AccentState 可以控制着窗口的多种效果,通过改变它的值,我们可以实现 Aero、Acrylic 等多种效果。对于这些效果的研究,可以参见 《使用 SetWindowCompositionAttribute 来控制程序的窗口边框和背景》,里面介绍的十分详尽。下面我们来看看 WindowEffect 中给窗口添加 Acrylic 和 Aero 效果的方法:

    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
        # 开启Aero
        self.SetWindowCompositionAttribute(int(hWnd), pointer(self.winCompAttrData))
    

    虽然亚克力的视觉效果很不错,但是在拖动窗口时会出现窗口卡顿问题,一种在本机的解决方案是去掉 高级系统设置 -> 性能 -> 拖动时显示窗口内容 复选框的 √ :

    WindowsFramelessWindow 类

    最后我们还剩一个窗口拉伸问题,为了解决这个问题,我们需要定义一个无边框窗口 WindowsFramelessWindow 类。在构造函数里面我们利用 WindowEffect 类给无边框窗口加上了窗口阴影和窗口动画,还有一点需要强调的是,我们不是简单地用 self.setWindowFlags(Qt.FramelessWindowHint) 来取消边框,而要或上原本的窗口标志,目的是解决点击任务栏图标窗口无法最小化或者还原的问题。下面是无边框窗口的构造函数:

    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)
            # remove window border
            self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint)
            # add DWM shadow and window animation
            self.windowEffect.addWindowAnimation(self.winId())
            if not isinstance(self, AcrylicWindow):
                self.windowEffect.addShadowEffect(self.winId())
            # solve issue #5
            self.windowHandle().screenChanged.connect(self.__onScreenChanged)
            self.resize(500, 500)
            self.titleBar.raise_()
    

    为了实现窗口拉伸,我们需要在 nativeEvent() 中处理 WM_NCHITTEST 消息,来告诉 Windows 光标已经到了窗口的边沿,该改变光标的样式并允许我们拉伸窗口了。实现方式如下:

    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

    在处理这两个消息的时候,我们会调用 win32apiwin32gui 中的一些接口函数,所以需要先定义一些结构体:

    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 消息且窗口最大化或全屏时,需要对窗口的边框坐标做出调整,把多出来的部分给切掉。仅仅这么做还不够,有时候用户可能把自动隐藏任务栏功能开了起来,像谷歌浏览器最大化后,底部(假设任务栏在底部)可以看到两个像素的任务栏边框,鼠标移动到底部任务栏就会弹出。对于我们实现的这个无边框窗口,由于调整后的窗口大小正好等于显示器的分辨率,鼠标移动到底部不会有任何反应。解决方案就是把窗口底部减去两个像素的高度,这样就能看到任务栏了。下面是处理这个消息的代码:

    def nativeEvent(self, eventType, message):
        """ 处理windows消息 """
        msg = MSG.from_address(message.__int__())
        # 此处省略处理 WM_NCHITTEST 消息的代码
        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)
            # adjust the size of client rect
            if isMax and not isFull:
                thickness = win_utils.getResizeBorderThickness(msg.hWnd)
                rect.top += thickness
                rect.left += thickness
                rect.right -= thickness
                rect.bottom -= thickness
            # handle the situation that an auto-hide taskbar is enabled
            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)
    

    这样整个自定义无边框窗口的解决方案就介绍完了,更多代码请移步 PyQt-Frameless-Window,使用过程中有遇到任何问题请在 github 上提 issue,以上~~