如何在pyqt中自定义无边框窗口


前言

之前写过很多关于无边框窗口并给窗口添加特效的博客,按照时间线罗列如下:

  1. 如何在pyqt中实现窗口磨砂效果
  2. 如何在pyqt中实现win10亚克力效果
  3. 如何在pyqt中通过调用SetWindowCompositionAttribute实现Win10亚克力效果
  4. 如何在pyqt中在实现无边框窗口的同时保留Windows窗口动画效果(一)
  5. 如何在pyqt中给无边框窗口添加DWM环绕阴影
  6. 如何在pyqt中在实现无边框窗口的同时保留Windows窗口动画效果(二)

里面有几篇博客用了 C++ 的 dll,虽然也给出了不用 dll 的实现方法,但还是觉得先前写的自定义无边框窗口的解决方案有些混乱,所以这次就总结在这一篇博客里面,并且只用 Python 的 ctypespywin32 来解决无边框窗口的问题。先来看看无边框窗口的效果:

无边框窗口

需要解决的问题

在pyqt中只要 self.setWindowFlags(Qt.FramelessWindowHint)就可以实现边框的去除,但是没了边框会带来一系列问题:

  • 窗口无法移动
  • 窗口无法拉伸
  • 窗口动画消失
  • 窗口阴影消失

下面我们会一个个地解决上述问题,并且给出 Windows 的 Aero 和 Acrylic 窗口特效的实现方法。

自定义标题栏

为了还原窗口的移动、最大化、最小化和关闭功能,我们需要实现一个标题栏 TitleBar。注意下面只会给出关键代码,完整代码请移步 github

窗口移动

要实现窗口移动,我们需要重写标题栏的 mousePressEvent(),并调用 win32api.SendMessage()win32gui.ReleaseCapture(),将鼠标按下并拖动的消息 win32con.SC_MOVE + win32con.HTCAPTION 发送给 Windows,让它知道该拖动窗口了。下面是实现代码:

def mousePressEvent(self, event):
    """ 移动窗口 """
    # 判断鼠标点击位置是否允许拖动
    if self.__isPointInDragRegion(event.pos()):
        ReleaseCapture()
        SendMessage(self.window().winId(), win32con.WM_SYSCOMMAND,
                    win32con.SC_MOVE + win32con.HTCAPTION, 0)
        event.ignore()

窗口最大化、最小化、还原和关闭

当我们双击标题栏时,窗口应该由正常大小变为最大化状态,或者由最大化状态还原为正常大小,为了实现这个功能,我们需要重写标题栏的 mouseDoubleClickEvent()。而普通的最大化、最小化和关闭功能只需将按钮的点击信号连接到槽函数即可,下面是具体代码:

def __initWidget(self):
    """ 初始化小部件 """
    self.setFixedHeight(40)
    self.setAttribute(Qt.WA_StyledBackground)
    self.__setQss()
    # 将按钮的点击信号连接到槽函数
    self.minBt.clicked.connect(self.window().showMinimized)
    self.maxBt.clicked.connect(self.__showRestoreWindow)
    self.closeBt.clicked.connect(self.window().close)

def mouseDoubleClickEvent(self, event):
    """ 双击最大化/还原窗口 """
    self.__showRestoreWindow()

def __showRestoreWindow(self):
    """ 复原窗口并更换最大化按钮的图标 """
    if self.window().isMaximized():
        self.window().showNormal()
        # 更新标志位用于更换图标
        self.maxBt.setMaxState(False)
    else:
        self.window().showMaximized()
        self.maxBt.setMaxState(True)

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

def addWindowAnimation(self, hWnd):
    """ 还原窗口动画效果

    Parameters
    ----------
    hWnd : int or `sip.voidptr`
        窗口句柄
    """
    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[
    0
]
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[
        0
    ]
    self.accentPolicy.GradientColor = gradientColor
    self.accentPolicy.AccentFlags = accentFlags
    self.accentPolicy.AnimationId = animationId
    # 开启亚克力
    self.SetWindowCompositionAttribute(hWnd, pointer(self.winCompAttrData))

def setAeroEffect(self, hWnd):
    """ 给窗口开启Aero效果

    Parameter
    ----------
    hWnd: int or `sip.voidptr`
        窗口句柄
    """
    self.accentPolicy.AccentState = ACCENT_STATE.ACCENT_ENABLE_BLURBEHIND.value[0]
    # 开启Aero
    self.SetWindowCompositionAttribute(hWnd, pointer(self.winCompAttrData))

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

FramelessWindow 类

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

class FramelessWindow(QWidget):

    BORDER_WIDTH = 5

    def __init__(self, parent=None):
        super().__init__(parent)
        self.monitor_info = None
        self.titleBar = TitleBar(self)
        self.windowEffect = WindowEffect()
        # 取消边框
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowSystemMenuHint |
                            Qt.WindowMinimizeButtonHint | Qt.WindowMaximizeButtonHint)
        # 添加阴影和窗口动画
        self.windowEffect.addShadowEffect(self.winId())
        self.windowEffect.addWindowAnimation(self.winId())
        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:
        xPos = (win32api.LOWORD(msg.lParam) - self.frameGeometry().x()) % 65536
        yPos = win32api.HIWORD(msg.lParam) - self.frameGeometry().y()
        w, h = self.width(), self.height()
        lx = xPos < self.BORDER_WIDTH
        rx = xPos + 9 > 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)

窗口最大化

至此,我们已经解决了罗列出来的所有问题,但是新的问题也接踵而来,那就是

如果还原窗口动画,就会导致窗口最大化时尺寸超过正确的显示器尺寸,甚至最大化之后又会有新的标题栏跑出来。

要解决这个问题必须在 nativeEvent 中处理另外两个消息:WM_NCCALCSIZEWM_GETMINMAXINFO

结构体

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

class MINMAXINFO(Structure):
    _fields_ = [
        ("ptReserved",      POINT),
        ("ptMaxSize",       POINT),
        ("ptMaxPosition",   POINT),
        ("ptMinTrackSize",  POINT),
        ("ptMaxTrackSize",  POINT),
    ]


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))
    ]

处理消息

下面是处理这两个消息用到的代码,注释讲解地很详细:

def nativeEvent(self, eventType, message):
    """ 处理windows消息 """
    msg = MSG.from_address(message.__int__())
    # 此处省略处理 WM_NCHITTEST 消息的代码
    if msg.message == win32con.WM_NCCALCSIZE:
        if self.isWindowMaximized(msg.hWnd):
            self.monitorNCCALCSIZE(msg)
        return True, 0
    elif msg.message == win32con.WM_GETMINMAXINFO:
        if self.isWindowMaximized(msg.hWnd):
            window_rect = win32gui.GetWindowRect(msg.hWnd)
            if not window_rect:
                return False, 0
            # 获取显示器句柄
            monitor = win32api.MonitorFromRect(window_rect)
            if not monitor:
                return False, 0
            # 获取显示器信息
            monitor_info = win32api.GetMonitorInfo(monitor)
            monitor_rect = monitor_info['Monitor']
            work_area = monitor_info['Work']
            # 将lParam转换为MINMAXINFO指针
            info = cast(msg.lParam, POINTER(MINMAXINFO)).contents
            # 调整窗口大小
            info.ptMaxSize.x = work_area[2] - work_area[0]
            info.ptMaxSize.y = work_area[3] - work_area[1]
            info.ptMaxTrackSize.x = info.ptMaxSize.x
            info.ptMaxTrackSize.y = info.ptMaxSize.y
            # 修改左上角坐标
            info.ptMaxPosition.x = abs(window_rect[0] - monitor_rect[0])
            info.ptMaxPosition.y = abs(window_rect[1] - monitor_rect[1])
            return True, 1

    return QWidget.nativeEvent(self, eventType, message)

def resizeEvent(self, e):
    """ 改变标题栏大小 """
    super().resizeEvent(e)
    self.titleBar.resize(self.width(), 40)
    # 更新最大化按钮图标
    if self.isWindowMaximized(int(self.winId())):
        self.titleBar.maxBt.setMaxState(True)

def monitorNCCALCSIZE(self, msg: MSG):
    """ 调整窗口大小 """
    monitor = win32api.MonitorFromWindow(msg.hWnd)
    # 如果没有保存显示器信息就直接返回,否则接着调整窗口大小
    if monitor is None and not self.monitor_info:
        return
    elif monitor is not None:
        self.monitor_info = win32api.GetMonitorInfo(monitor)
    # 调整窗口大小
    params = cast(msg.lParam, POINTER(NCCALCSIZE_PARAMS)).contents
    params.rgrc[0].left = self.monitor_info['Work'][0]
    params.rgrc[0].top = self.monitor_info['Work'][1]
    params.rgrc[0].right = self.monitor_info['Work'][2]
    params.rgrc[0].bottom = self.monitor_info['Work'][3]

写在最后

这样整个自定义无边框窗口的解决方案就介绍完了,更多代码请移步 github,以上~~


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM