bubbleUI 0.4.4a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bubble/dev_titlebar.py ADDED
@@ -0,0 +1,2900 @@
1
+ """Windows 11 风格自定义标题栏(保留 Windows 原生视觉效果)。
2
+
3
+ 特点
4
+ ----
5
+ * 窗口阴影、Windows 11 圆角、最小化/最大化/还原的 DWM 动画全部保留;
6
+ * 拖动标题栏移动窗口、双击最大化/还原;
7
+ * 自带 **Qt 自绘系统菜单**:左键按下应用图标、右键按下标题栏
8
+ (按钮区域除外)或 Alt+Space 弹出;菜单项为
9
+ 还原/移动/大小/最小化/最大化/关闭,支持“移动”“大小”提示模式;
10
+ * **双击应用图标直接关闭窗口**(不经过菜单里的“关闭”);
11
+ * Aero Snap、Win + 方向键贴靠、任务栏预览照常工作;
12
+ * 最大化按钮悬停时出现 Windows 11 的“贴靠布局”悬浮面板
13
+ (按钮走原生 HTMINBUTTON / HTMAXBUTTON / HTCLOSE 命中测试);
14
+ * 按钮点击由组件自己 SetCapture + WM_SYSCOMMAND 完成:最小化/最大化可靠,
15
+ 也不会出现 DefWindowProc 画出来的“白底原生按钮”残影;
16
+ * 标题、图标、深/浅色主题可换,窗口变窄时标题自动省略;
17
+ * 标题栏高度可在构造时用 ``height=...`` 指定,按钮尺寸、命中测试区域
18
+ 与 QMainWindow 的预留边距都会同步(见 ``set_title_bar_height()``);
19
+ * 窗口无边框,但 Qt 的 resize()/move() 仍然按客户区精确生效。
20
+
21
+ 用法
22
+ ----
23
+ 继承 ``TitleBarWindow``(QVBoxLayout + 内容区)::
24
+
25
+ class MyWindow(TitleBarWindow):
26
+ def __init__(self):
27
+ super().__init__(title="我的应用", icon=QIcon("app.ico"), height=40)
28
+ self.body_layout.addWidget(QTextEdit())
29
+
30
+ 或者继承 ``TitleBarMainWindow``(QMainWindow,标题栏固定在顶部,
31
+ 菜单栏会显示在标题栏下方)::
32
+
33
+ class MyWindow(TitleBarMainWindow):
34
+ def __init__(self):
35
+ super().__init__(title="我的应用")
36
+ self.setCentralWidget(QTextEdit())
37
+
38
+ 注意
39
+ ----
40
+ * 标题栏高度在构造时用 ``height=...`` 指定(逻辑像素,默认取
41
+ ``TitleBar.HEIGHT``),运行时用 ``set_title_bar_height()`` 修改;按钮尺寸、非客户区命中测试
42
+ 区域、``TitleBarMainWindow`` 为标题栏预留的边距都由同一个高度推导。
43
+ 直接改 ``TitleBar.HEIGHT`` 只对之后新建的实例生效。
44
+ * 仅在 Windows 上启用原生效果;其他平台退化为普通窗口。
45
+ * 如果不需要“原生按钮”(例如想自己拦截最小化/关闭),可以传
46
+ ``native_caption=False``,此时按钮由 Qt 处理、没有贴靠布局面板。
47
+ * 窗口被系统最大化时,Qt 可能因为“无边框 + 铺满屏幕”而同时报告
48
+ ``WindowFullScreen``,判断状态请用 ``isMaximized()``。
49
+ """
50
+
51
+ from PySide6.QtWidgets import *
52
+ from PySide6.QtCore import *
53
+ from PySide6.QtGui import *
54
+
55
+ import ctypes
56
+ import ctypes.wintypes as wt
57
+ import sys
58
+ import traceback
59
+
60
+ from typing import Optional
61
+
62
+ def set_style(widget, class_name: str):
63
+ '''
64
+ 设置按钮的class属性并刷新样式
65
+ '''
66
+ # 1. 设置class属性
67
+ widget.setProperty('class', class_name)
68
+
69
+ # 2. 强制样式刷新
70
+ widget.style().unpolish(widget)
71
+ widget.style().polish(widget)
72
+
73
+ # 3. 触发重绘
74
+ widget.update()
75
+
76
+ _FONT_FAMILIES = ["Segoe Fluent Icons", "Segoe MDL2 Assets"]
77
+
78
+ class FontIcon(QLabel):
79
+ """图标字体标签(Segoe Fluent Icons / Segoe MDL2 Assets),支持原地旋转。
80
+
81
+ angle 属性可直接交给 QPropertyAnimation 做旋转动画。
82
+ icon_color / icon_size 也可由 qss 用 qproperty-* 覆盖。
83
+
84
+ 配色由 qss 决定:默认取 `.FontIcon`(或全局文字色)的 color;
85
+ 需要单独配色的位置在 qss 里用自己的 class 声明 color(如 `.ExpanderArrow`),
86
+ 代码只在显式传入 color 时才把颜色写进控件自身样式表。
87
+
88
+ 字号通过「控件自身样式表」设置:全局样式表里的 `* { font-size: ... }`
89
+ 会覆盖 setFont(),但覆盖不了控件自身样式表,因此图标字号不会被带跑。
90
+
91
+ 示例:
92
+ icon = FontIcon("\uE713", size=24) # 设置齿轮;颜色交给 qss 的 .FontIcon
93
+ icon = FontIcon("\uE70D", size=12, color="#F7F7F7") # 单独配色
94
+ icon.set_angle(90) # 原地旋转 90°
95
+ # angle 是 Qt Property,可直接交给 QPropertyAnimation 做动画
96
+ """
97
+
98
+ STYLE_CLASS = "FontIcon"
99
+
100
+ def __init__(self, icon="", size: int = 20, color: Optional[str] = None, parent=None):
101
+ super().__init__(icon, parent)
102
+
103
+ self._color = QColor(color) if color is not None else None
104
+ self._icon_size = int(size)
105
+ self._angle = 0.0
106
+ self._pixmap = None
107
+ self._pixmap_key = None
108
+
109
+ self.setAlignment(Qt.AlignCenter)
110
+ self.setMinimumWidth(0)
111
+ self.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
112
+ self._apply_style()
113
+ set_style(self, self.STYLE_CLASS)
114
+
115
+ def _apply_style(self):
116
+ """把字号写进控件自身样式表(优先级高于全局 `*` 规则)。
117
+
118
+ 只有显式指定颜色时才写入 color,否则交给 qss 的 `.FontIcon` 决定。
119
+ """
120
+ f = self.font()
121
+ f.setPixelSize(self._icon_size)
122
+ f.setFamilies(_FONT_FAMILIES)
123
+ self.setFont(f)
124
+ # 不在这里写 background:背景交给样式表按层级(level)决定
125
+ css = f"font-size: {self._icon_size}px;"
126
+ if self._color is not None:
127
+ css += f" color: {self._color.name()};"
128
+ self.setStyleSheet(css)
129
+ self.updateGeometry()
130
+
131
+ def _effective_color(self) -> QColor:
132
+ """当前生效的图标颜色:显式颜色优先,否则取 qss 设置的前景色。"""
133
+ if self._color is not None:
134
+ return self._color
135
+ return self.palette().color(self.foregroundRole())
136
+
137
+ # --------------------------- 原地旋转 ---------------------------
138
+ def get_angle(self) -> float:
139
+ return self._angle
140
+
141
+ def set_angle(self, angle: float):
142
+ angle = float(angle)
143
+ if angle != self._angle:
144
+ self._angle = angle
145
+ self.update()
146
+
147
+ angle = Property(float, get_angle, set_angle)
148
+
149
+ # --------------------------- 配色 / 字号 ---------------------------
150
+ def get_icon_color(self) -> QColor:
151
+ return self._effective_color()
152
+
153
+ def set_icon_color(self, color):
154
+ new_color = QColor(color) if color is not None else QColor()
155
+ if not new_color.isValid():
156
+ new_color = None
157
+ if new_color != self._color:
158
+ self._color = new_color
159
+ # 旋转时由 _effective_color() 自绘;未旋转时 QLabel 取样式表的颜色
160
+ self._apply_style()
161
+
162
+ def get_icon_size(self) -> int:
163
+ return self._icon_size
164
+
165
+ def set_icon_size(self, size: int):
166
+ size = int(size)
167
+ if size != self._icon_size:
168
+ self._icon_size = size
169
+ self._apply_style()
170
+
171
+ icon_color = Property(QColor, get_icon_color, set_icon_color)
172
+ icon_size = Property(int, get_icon_size, set_icon_size)
173
+
174
+ def _icon_pixmap(self) -> QPixmap:
175
+ """先把图标按 0° 渲染成 pixmap,再整体旋转绘制。
176
+
177
+ 直接把文字交给旋转后的 painter 画,在部分平台上不会输出任何像素,
178
+ 因此这里始终先渲染成位图再旋转。
179
+ """
180
+ color = self._effective_color()
181
+ dpr = self.devicePixelRatioF()
182
+ key = (self.size(), self.text(), self.font().key(), color.rgba(), dpr)
183
+ if self._pixmap is None or self._pixmap_key != key:
184
+ pm = QPixmap(int(self.width() * dpr), int(self.height() * dpr))
185
+ pm.setDevicePixelRatio(dpr)
186
+ pm.fill(Qt.transparent)
187
+
188
+ painter = QPainter(pm)
189
+ painter.setRenderHint(QPainter.Antialiasing, True)
190
+ painter.setRenderHint(QPainter.TextAntialiasing, True)
191
+ painter.setPen(color)
192
+ painter.setFont(self.font())
193
+ painter.drawText(QRect(QPoint(0, 0), self.size()), Qt.AlignCenter, self.text())
194
+ painter.end()
195
+
196
+ self._pixmap = pm
197
+ self._pixmap_key = key
198
+ return self._pixmap
199
+
200
+ def changeEvent(self, event):
201
+ # 主题 / 样式变化会改变 qss 决定的颜色,缓存的旋转位图必须失效
202
+ if event.type() in (QEvent.PaletteChange, QEvent.StyleChange, QEvent.FontChange):
203
+ self._pixmap = None
204
+ self._pixmap_key = None
205
+ super().changeEvent(event)
206
+
207
+ def paintEvent(self, event):
208
+ # 未旋转时走 QLabel 默认绘制,保证与普通图标标签完全一致
209
+ if self._angle % 360.0 == 0.0:
210
+ super().paintEvent(event)
211
+ return
212
+
213
+ painter = QPainter(self)
214
+ painter.setRenderHint(QPainter.Antialiasing, True)
215
+ painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
216
+
217
+ rect = QRectF(self.rect())
218
+ painter.translate(rect.center())
219
+ painter.rotate(self._angle)
220
+ painter.translate(-rect.center())
221
+ painter.drawPixmap(0, 0, self._icon_pixmap())
222
+
223
+
224
+ # =============================================================================
225
+ # Windows 原生窗口效果(DWM 阴影 / 最小化最大化动画 / 圆角 / 贴靠布局)
226
+ # =============================================================================
227
+ #
228
+ # 核心做法(与 Chromium / Electron 的隐藏标题栏方案一致):
229
+ # 1. 窗口本身仍然是一个“带边框”的普通 Win32 窗口(WS_OVERLAPPEDWINDOW),
230
+ # 因此 DWM 会照常提供:窗口阴影、最小化/最大化动画、Aero Snap、
231
+ # Win+方向键贴靠、右键系统菜单、任务栏预览等原生效果;
232
+ # 2. 用 WM_NCCALCSIZE 把非客户区“算成 0”,让客户区铺满整个窗口,
233
+ # 从而把原生标题栏藏起来,由我们自己画;
234
+ # 3. 用 WM_NCHITTEST 把标题栏映射成 HTCAPTION,把三个按钮映射成
235
+ # HTMINBUTTON / HTMAXBUTTON / HTCLOSE —— 这样点击、拖动、双击最大化、
236
+ # 右侧系统菜单、Windows 11 最大化按钮的“贴靠布局”悬浮面板全是系统行为;
237
+ # 4. 用 DWM 属性给窗口加圆角,并在系统主题变化时同步深/浅色。
238
+ #
239
+ # 注意:为了让 Qt 认为窗口没有边框(resize()/move() 不产生偏差),
240
+ # 窗口以 Qt.FramelessWindowHint 创建,随后再把原生边框样式补回去。
241
+ # 这是本文件里唯一“非标准”的一步,所有 DWM 效果都由补回样式保证。
242
+
243
+ _IS_WINDOWS = sys.platform == "win32"
244
+
245
+ # ------------------------------- Win32 常量 -------------------------------
246
+ WM_NCCALCSIZE = 0x0083
247
+ WM_NCHITTEST = 0x0084
248
+ WM_NCACTIVATE = 0x0086
249
+ WM_NCMOUSEMOVE = 0x00A0
250
+ WM_NCLBUTTONDOWN = 0x00A1
251
+ WM_NCLBUTTONUP = 0x00A2
252
+ WM_NCLBUTTONDBLCLK = 0x00A3
253
+ WM_NCRBUTTONDOWN = 0x00A4
254
+ WM_NCRBUTTONUP = 0x00A5
255
+ WM_NCMOUSELEAVE = 0x02A2
256
+ WM_CONTEXTMENU = 0x007B
257
+ WM_MOUSEMOVE = 0x0200
258
+ WM_LBUTTONDOWN = 0x0201
259
+ WM_LBUTTONUP = 0x0202
260
+ WM_RBUTTONDOWN = 0x0204
261
+ WM_KEYDOWN = 0x0100
262
+ WM_SYSKEYDOWN = 0x0104
263
+ WM_SETCURSOR = 0x0020
264
+ WM_CAPTURECHANGED = 0x0215
265
+ WM_CANCELMODE = 0x001F
266
+ WM_SYSCOMMAND = 0x0112
267
+ WM_SETTINGCHANGE = 0x001A
268
+ WM_GETMINMAXINFO = 0x0024
269
+ WM_SIZE = 0x0005
270
+ WM_STYLECHANGED = 0x007D
271
+
272
+ SC_MINIMIZE = 0xF020
273
+ SC_MAXIMIZE = 0xF030
274
+ SC_RESTORE = 0xF120
275
+ SC_KEYMENU = 0xF100
276
+ WM_CLOSE = 0x0010
277
+
278
+ VK_SPACE = 0x20
279
+ VK_ESCAPE = 0x1B
280
+
281
+ HTCLIENT = 1
282
+ HTCAPTION = 2
283
+ HTMINBUTTON = 8
284
+ HTMAXBUTTON = 9
285
+ HTCLOSE = 20
286
+ HTLEFT = 10
287
+ HTRIGHT = 11
288
+ HTTOP = 12
289
+ HTTOPLEFT = 13
290
+ HTTOPRIGHT = 14
291
+ HTBOTTOM = 15
292
+ HTBOTTOMLEFT = 16
293
+ HTBOTTOMRIGHT = 17
294
+
295
+ GWL_STYLE = -16
296
+ GWL_EXSTYLE = -20
297
+ WS_POPUP = 0x80000000
298
+ WS_OVERLAPPEDWINDOW = 0x00CF0000 # WS_OVERLAPPED|WS_CAPTION|WS_SYSMENU|WS_THICKFRAME|WS_MINIMIZEBOX|WS_MAXIMIZEBOX
299
+ WS_SYSMENU = 0x00080000
300
+ WS_MINIMIZEBOX = 0x00020000
301
+ WS_MAXIMIZEBOX = 0x00010000
302
+ WS_EX_APPWINDOW = 0x00040000
303
+ WS_EX_TOOLWINDOW = 0x00000080
304
+
305
+ SWP_NOSIZE = 0x0001
306
+ SWP_NOMOVE = 0x0002
307
+ SWP_NOZORDER = 0x0004
308
+ SWP_NOACTIVATE = 0x0010
309
+ SWP_FRAMECHANGED = 0x0020
310
+
311
+ SM_CXSIZEFRAME = 32
312
+ SM_CYSIZEFRAME = 33
313
+ SM_CXPADDEDBORDER = 92
314
+ SM_CXCURSOR = 13
315
+ SM_CYCURSOR = 14
316
+
317
+ # 系统光标资源(MAKEINTRESOURCE 编号)
318
+ IDC_ARROW = 32512
319
+ IDC_SIZEWE = 32644
320
+ IDC_SIZENS = 32645
321
+ IDC_SIZEALL = 32646
322
+
323
+ DWMWA_USE_IMMERSIVE_DARK_MODE = 20
324
+ DWMWA_USE_IMMERSIVE_DARK_MODE_OLD = 19
325
+ DWMWA_WINDOW_CORNER_PREFERENCE = 33
326
+ DWMWCP_ROUND = 2
327
+
328
+ TME_LEAVE = 0x00000002
329
+ TME_NONCLIENT = 0x00000010
330
+
331
+ QWIDGETSIZE_MAX = 16777215
332
+
333
+
334
+ class _NCCALCSIZE_PARAMS(ctypes.Structure):
335
+ _fields_ = [("rgrc", wt.RECT * 3), ("lppos", ctypes.c_void_p)]
336
+
337
+
338
+ class _MONITORINFO(ctypes.Structure):
339
+ _fields_ = [("cbSize", wt.DWORD), ("rcMonitor", wt.RECT),
340
+ ("rcWork", wt.RECT), ("dwFlags", wt.DWORD)]
341
+
342
+
343
+ class _MINMAXINFO(ctypes.Structure):
344
+ _fields_ = [("ptReserved", wt.POINT), ("ptMaxSize", wt.POINT),
345
+ ("ptMaxPosition", wt.POINT), ("ptMinTrackSize", wt.POINT),
346
+ ("ptMaxTrackSize", wt.POINT)]
347
+
348
+
349
+ class _TRACKMOUSEEVENT(ctypes.Structure):
350
+ _fields_ = [("cbSize", wt.DWORD), ("dwFlags", wt.DWORD),
351
+ ("hwndTrack", wt.HWND), ("dwHoverTime", wt.DWORD)]
352
+
353
+
354
+ if _IS_WINDOWS:
355
+ _user32 = ctypes.WinDLL("user32", use_last_error=True)
356
+ _dwmapi = ctypes.WinDLL("dwmapi", use_last_error=True)
357
+
358
+ _user32.GetWindowLongPtrW.restype = ctypes.c_ssize_t
359
+ _user32.GetWindowLongPtrW.argtypes = [wt.HWND, ctypes.c_int]
360
+ _user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
361
+ _user32.SetWindowLongPtrW.argtypes = [wt.HWND, ctypes.c_int, ctypes.c_ssize_t]
362
+ _user32.SetWindowPos.restype = wt.BOOL
363
+ _user32.SetWindowPos.argtypes = [wt.HWND, wt.HWND, ctypes.c_int, ctypes.c_int,
364
+ ctypes.c_int, ctypes.c_int, wt.UINT]
365
+ _user32.DefWindowProcW.restype = ctypes.c_ssize_t
366
+ _user32.DefWindowProcW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM]
367
+ _user32.IsZoomed.restype = wt.BOOL
368
+ _user32.IsZoomed.argtypes = [wt.HWND]
369
+ _user32.GetClientRect.restype = wt.BOOL
370
+ _user32.GetClientRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)]
371
+ _user32.ScreenToClient.restype = wt.BOOL
372
+ _user32.ScreenToClient.argtypes = [wt.HWND, ctypes.POINTER(wt.POINT)]
373
+ _user32.MonitorFromWindow.restype = wt.HANDLE
374
+ _user32.MonitorFromWindow.argtypes = [wt.HWND, wt.DWORD]
375
+ _user32.GetMonitorInfoW.restype = wt.BOOL
376
+ _user32.GetMonitorInfoW.argtypes = [wt.HANDLE, ctypes.POINTER(_MONITORINFO)]
377
+ _user32.TrackMouseEvent.restype = wt.BOOL
378
+ _user32.TrackMouseEvent.argtypes = [ctypes.POINTER(_TRACKMOUSEEVENT)]
379
+ _user32.GetSystemMetrics.restype = ctypes.c_int
380
+ _user32.GetSystemMetrics.argtypes = [ctypes.c_int]
381
+ _user32.SetCapture.restype = wt.HWND
382
+ _user32.SetCapture.argtypes = [wt.HWND]
383
+ _user32.ReleaseCapture.restype = wt.BOOL
384
+ _user32.ReleaseCapture.argtypes = []
385
+ _user32.GetCapture.restype = wt.HWND
386
+ _user32.GetCapture.argtypes = []
387
+ _user32.GetCursorPos.restype = wt.BOOL
388
+ _user32.GetCursorPos.argtypes = [ctypes.POINTER(wt.POINT)]
389
+ _user32.PostMessageW.restype = wt.BOOL
390
+ _user32.PostMessageW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM]
391
+ _user32.GetWindowRect.restype = wt.BOOL
392
+ _user32.GetWindowRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)]
393
+ _user32.SetCursorPos.restype = wt.BOOL
394
+ _user32.SetCursorPos.argtypes = [ctypes.c_int, ctypes.c_int]
395
+ _user32.SetCursor.restype = wt.HANDLE
396
+ _user32.SetCursor.argtypes = [wt.HANDLE]
397
+ _user32.LoadCursorW.restype = wt.HANDLE
398
+ _user32.LoadCursorW.argtypes = [wt.HINSTANCE, ctypes.c_void_p]
399
+ _dwmapi.DwmSetWindowAttribute.restype = ctypes.c_long
400
+ _dwmapi.DwmSetWindowAttribute.argtypes = [wt.HWND, wt.DWORD, ctypes.c_void_p, wt.DWORD]
401
+
402
+ try: # Windows 10 1607+
403
+ _user32.GetDpiForWindow.restype = wt.UINT
404
+ _user32.GetDpiForWindow.argtypes = [wt.HWND]
405
+ _user32.GetSystemMetricsForDpi.restype = ctypes.c_int
406
+ _user32.GetSystemMetricsForDpi.argtypes = [ctypes.c_int, wt.UINT]
407
+ _HAS_DPI_API = True
408
+ except AttributeError:
409
+ _HAS_DPI_API = False
410
+ else:
411
+ _user32 = None
412
+ _dwmapi = None
413
+ _HAS_DPI_API = False
414
+
415
+
416
+ def _signed_lparam(lparam):
417
+ """把 lParam 拆成屏幕坐标 (x, y)(考虑 16 位有符号)。"""
418
+ lparam = int(lparam) & 0xFFFFFFFF
419
+ x = lparam & 0xFFFF
420
+ y = (lparam >> 16) & 0xFFFF
421
+ if x >= 0x8000:
422
+ x -= 0x10000
423
+ if y >= 0x8000:
424
+ y -= 0x10000
425
+ return x, y
426
+
427
+
428
+ def _screen_point(lparam):
429
+ x, y = _signed_lparam(lparam)
430
+ return wt.POINT(x, y)
431
+
432
+
433
+ def _system_metrics_for_dpi(hwnd, index):
434
+ """按窗口所在显示器的 DPI 取系统尺寸,保证 125%/150% 缩放下边框比例正确。"""
435
+ dpi = 96
436
+ if _HAS_DPI_API:
437
+ try:
438
+ dpi = _user32.GetDpiForWindow(hwnd) or 96
439
+ except OSError:
440
+ dpi = 96
441
+ if _HAS_DPI_API:
442
+ try:
443
+ return _user32.GetSystemMetricsForDpi(index, dpi)
444
+ except OSError:
445
+ pass
446
+ return _user32.GetSystemMetrics(index)
447
+
448
+
449
+ def _resize_border_size(hwnd):
450
+ """返回 (左右, 上下) 的可缩放边框厚度(物理像素)。"""
451
+ fx = (_system_metrics_for_dpi(hwnd, SM_CXSIZEFRAME)
452
+ + _system_metrics_for_dpi(hwnd, SM_CXPADDEDBORDER))
453
+ fy = (_system_metrics_for_dpi(hwnd, SM_CYSIZEFRAME)
454
+ + _system_metrics_for_dpi(hwnd, SM_CXPADDEDBORDER))
455
+ return max(fx, 1), max(fy, 1)
456
+
457
+
458
+ def _window_rect(hwnd):
459
+ """窗口物理矩形(屏幕坐标);失败返回 None。"""
460
+ if not _IS_WINDOWS:
461
+ return None
462
+ rect = wt.RECT()
463
+ try:
464
+ if not _user32.GetWindowRect(wt.HWND(hwnd), ctypes.byref(rect)):
465
+ return None
466
+ except OSError:
467
+ return None
468
+ return rect
469
+
470
+
471
+ _CURSOR_CACHE = {}
472
+
473
+
474
+ def _system_cursor(cursor_id):
475
+ """加载(并缓存)系统光标句柄;失败返回 None。"""
476
+ if not _IS_WINDOWS:
477
+ return None
478
+ if cursor_id in _CURSOR_CACHE:
479
+ return _CURSOR_CACHE[cursor_id]
480
+ handle = None
481
+ try:
482
+ # MAKEINTRESOURCE:整数资源编号按 LPWSTR 指针传入
483
+ handle = _user32.LoadCursorW(None, ctypes.c_void_p(cursor_id))
484
+ except OSError:
485
+ handle = None
486
+ _CURSOR_CACHE[cursor_id] = handle
487
+ return handle
488
+
489
+
490
+ def _dwm_set_int(hwnd, attribute, value):
491
+ """DwmSetWindowAttribute 的 int 版本;旧系统不支持时静默失败。"""
492
+ if not _IS_WINDOWS:
493
+ return False
494
+ data = ctypes.c_int(value)
495
+ try:
496
+ hr = _dwmapi.DwmSetWindowAttribute(
497
+ wt.HWND(hwnd), attribute, ctypes.byref(data), ctypes.sizeof(data))
498
+ except OSError:
499
+ return False
500
+ return hr == 0
501
+
502
+
503
+ def _apply_dwm_effects(hwnd, dark: bool, rounded: bool = True):
504
+ """把 Win11 的深色标题栏与圆角交给 DWM(阴影/动画本来就是 DWM 的默认行为)。"""
505
+ if not _IS_WINDOWS:
506
+ return
507
+ if not _dwm_set_int(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, 1 if dark else 0):
508
+ # Windows 10 1903 ~ 20H1 之间的属性编号是 19
509
+ _dwm_set_int(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_OLD, 1 if dark else 0)
510
+ if rounded:
511
+ _dwm_set_int(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND)
512
+
513
+
514
+ def _inject_native_frame_styles(hwnd):
515
+ """把 Qt 的无边框窗口“变成”普通带边框窗口。
516
+
517
+ Qt 创建 FramelessWindowHint 窗口时会用 WS_POPUP;这里补回
518
+ WS_OVERLAPPEDWINDOW,DWM 才会给窗口阴影、动画和贴靠行为。
519
+ WM_NCCALCSIZE 会把新增的非客户区全部算进客户区,所以视觉上依旧无边框。
520
+ """
521
+ if not _IS_WINDOWS:
522
+ return
523
+ style = _user32.GetWindowLongPtrW(wt.HWND(hwnd), GWL_STYLE)
524
+ style = (style & ~(WS_POPUP | WS_OVERLAPPEDWINDOW)) | WS_OVERLAPPEDWINDOW
525
+ _user32.SetWindowLongPtrW(wt.HWND(hwnd), GWL_STYLE, style)
526
+
527
+ ex_style = _user32.GetWindowLongPtrW(wt.HWND(hwnd), GWL_EXSTYLE)
528
+ ex_style = (ex_style | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW
529
+ _user32.SetWindowLongPtrW(wt.HWND(hwnd), GWL_EXSTYLE, ex_style)
530
+
531
+ # 让 Windows 重新计算窗口边框(不改变位置和大小)
532
+ _user32.SetWindowPos(wt.HWND(hwnd), wt.HWND(0), 0, 0, 0, 0,
533
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER
534
+ | SWP_NOACTIVATE | SWP_FRAMECHANGED)
535
+
536
+
537
+ def _monitor_work_area(hwnd):
538
+ """窗口所在显示器的工作区(物理像素,已排除任务栏)。"""
539
+ monitor = _user32.MonitorFromWindow(wt.HWND(hwnd), 2) # MONITOR_DEFAULTTONEAREST
540
+ info = _MONITORINFO()
541
+ info.cbSize = ctypes.sizeof(_MONITORINFO)
542
+ if not _user32.GetMonitorInfoW(monitor, ctypes.byref(info)):
543
+ return None
544
+ return info.rcWork
545
+
546
+
547
+ def system_is_dark() -> bool:
548
+ """当前 Windows 系统是深色模式吗?(“个性化 → 颜色 → 应用模式”)"""
549
+ if not _IS_WINDOWS:
550
+ return False
551
+ try:
552
+ import winreg
553
+ except ImportError:
554
+ return False
555
+ try:
556
+ key = winreg.OpenKey(
557
+ winreg.HKEY_CURRENT_USER,
558
+ r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
559
+ try:
560
+ value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
561
+ finally:
562
+ key.Close()
563
+ return int(value) == 0
564
+ except OSError:
565
+ return False
566
+
567
+
568
+ # =============================================================================
569
+ # 标题栏主题(Windows 11 视觉规范)
570
+ # =============================================================================
571
+
572
+ _TITLEBAR_LIGHT = dict(
573
+ bar="#F3F3F3",
574
+ title="#1A1A1A",
575
+ title_inactive="#6B6B6B",
576
+ caption="#1A1A1A",
577
+ caption_inactive="#8A8A8A",
578
+ caption_hover="#E9E9E9",
579
+ caption_pressed="#DBDBDB",
580
+ close_hover="#C42B1C",
581
+ close_pressed="#B01F14",
582
+ )
583
+
584
+ _TITLEBAR_DARK = dict(
585
+ bar="#202020",
586
+ title="#FFFFFF",
587
+ title_inactive="#9A9A9A",
588
+ caption="#FFFFFF",
589
+ caption_inactive="#9A9A9A",
590
+ caption_hover="#333333",
591
+ caption_pressed="#454545",
592
+ close_hover="#C42B1C",
593
+ close_pressed="#B01F14",
594
+ )
595
+
596
+
597
+ def _titlebar_stylesheet(dark: bool) -> str:
598
+ c = _TITLEBAR_DARK if dark else _TITLEBAR_LIGHT
599
+ return f"""
600
+ QWidget#TitleBar {{
601
+ background-color: {c['bar']};
602
+ }}
603
+ QLabel#TitleBarTitle {{
604
+ color: {c['title']};
605
+ background: transparent;
606
+ font-size: 12px;
607
+ padding-left: 2px;
608
+ }}
609
+ QLabel#TitleBarTitle[active="false"] {{
610
+ color: {c['title_inactive']};
611
+ }}
612
+ QLabel#CaptionButton {{
613
+ background: transparent;
614
+ color: {c['caption']};
615
+ }}
616
+ QLabel#CaptionButton[windowActive="false"] {{
617
+ color: {c['caption_inactive']};
618
+ }}
619
+ QLabel#CaptionButton[captionState="hover"] {{
620
+ background-color: {c['caption_hover']};
621
+ }}
622
+ QLabel#CaptionButton[captionState="pressed"] {{
623
+ background-color: {c['caption_pressed']};
624
+ }}
625
+ /* 关闭按钮:悬停变红、图标变白(Windows 11 行为) */
626
+ QLabel#CaptionButton[captionRole="close"] {{
627
+ color: {c['caption']};
628
+ }}
629
+ QLabel#CaptionButton[captionRole="close"][captionState="hover"] {{
630
+ background-color: {c['close_hover']};
631
+ color: #FFFFFF;
632
+ }}
633
+ QLabel#CaptionButton[captionRole="close"][captionState="pressed"] {{
634
+ background-color: {c['close_pressed']};
635
+ color: #FFFFFF;
636
+ }}
637
+ """
638
+
639
+
640
+ def _repolish(widget):
641
+ """动态属性改变后强制刷新 qss。"""
642
+ widget.style().unpolish(widget)
643
+ widget.style().polish(widget)
644
+ widget.update()
645
+
646
+
647
+ # =============================================================================
648
+ # 标题栏控件
649
+ # =============================================================================
650
+
651
+ _CAPTION_GLYPHS = {
652
+ "minimize": "\uE921",
653
+ "maximize": "\uE922",
654
+ "restore": "\uE923",
655
+ "close": "\uE8BB",
656
+ }
657
+
658
+ _CAPTION_ACCESSIBLE_NAMES = {
659
+ "minimize": "最小化",
660
+ "maximize": "最大化",
661
+ "close": "关闭",
662
+ }
663
+
664
+ #: 标题栏默认高度(逻辑像素)。``TitleBar.HEIGHT`` 保留这个值,
665
+ #: 子类/老代码仍可覆盖类属性;实例高度请用构造参数 ``height=``。
666
+ _DEFAULT_TITLEBAR_HEIGHT = 48
667
+
668
+ #: 标题栏按钮宽度(逻辑像素);按钮高度始终等于标题栏高度。
669
+ _CAPTION_BUTTON_WIDTH = 48
670
+
671
+
672
+ class CaptionButton(FontIcon):
673
+ """标题栏按钮(最小化 / 最大化 / 关闭)。
674
+
675
+ 默认(native_caption=True)由 Windows 非客户区命中测试接管:
676
+ 鼠标事件不会到达 Qt,按钮只负责画图标和悬停/按下背景。
677
+ 若标题栏使用 native_caption=False,则按钮自己处理鼠标事件,
678
+ 并发出 clicked 信号。
679
+
680
+ 按钮高度由标题栏高度决定(见 ``height`` 参数),保证点击/悬停区域
681
+ 与视觉上的按钮完全一致。
682
+ """
683
+
684
+ hovered = Signal(bool)
685
+ pressed = Signal(bool)
686
+ clicked = Signal()
687
+
688
+ def __init__(self, role: str, parent=None, *,
689
+ height: int = _DEFAULT_TITLEBAR_HEIGHT):
690
+ super().__init__(_CAPTION_GLYPHS[role], size=10, parent=parent)
691
+ self._role = role
692
+ self._height = max(1, int(height))
693
+ self._caption_state = "normal"
694
+ self.setObjectName("CaptionButton")
695
+ self.setProperty("captionRole", role)
696
+ self.setProperty("captionState", "normal")
697
+ self.setProperty("windowActive", True)
698
+ self.setAccessibleName(_CAPTION_ACCESSIBLE_NAMES[role])
699
+ # Windows 11 标题栏按钮:宽度固定(_CAPTION_BUTTON_WIDTH),高度铺满标题栏
700
+ self.setFixedSize(_CAPTION_BUTTON_WIDTH, self._height)
701
+ self.setAlignment(Qt.AlignCenter)
702
+ self.setFocusPolicy(Qt.NoFocus)
703
+ self.setCursor(Qt.ArrowCursor)
704
+ # 原生模式下鼠标事件由 Windows 非客户区命中测试接管;
705
+ # Qt 交互模式下会由 set_qt_interactive(True) 打开。
706
+ self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
707
+
708
+ # ---------------------------------------------------------------- 状态
709
+ def role(self) -> str:
710
+ return self._role
711
+
712
+ def caption_height(self) -> int:
713
+ """按钮当前高度(逻辑像素),始终等于标题栏高度。"""
714
+ return self._height
715
+
716
+ def set_caption_height(self, height: int):
717
+ """跟随标题栏高度变化(按钮即命中区域,二者必须同步)。"""
718
+ height = max(1, int(height))
719
+ if height == self._height:
720
+ return
721
+ self._height = height
722
+ self.setFixedSize(_CAPTION_BUTTON_WIDTH, height)
723
+
724
+ def set_qt_interactive(self, enabled: bool):
725
+ """切换成 Qt 交互模式(标题栏 native_caption=False 时使用)。"""
726
+ self.setAttribute(Qt.WA_TransparentForMouseEvents, not enabled)
727
+ if enabled:
728
+ self.setAttribute(Qt.WA_Hover, True)
729
+ self.setMouseTracking(True)
730
+
731
+ def set_caption_state(self, state: str):
732
+ state = state if state in ("hover", "pressed") else "normal"
733
+ if state == self._caption_state:
734
+ return
735
+ self._caption_state = state
736
+ self.setProperty("captionState", state)
737
+ _repolish(self)
738
+
739
+ def set_window_active(self, active: bool):
740
+ if bool(self.property("windowActive")) == active:
741
+ return
742
+ self.setProperty("windowActive", active)
743
+ _repolish(self)
744
+
745
+ def set_glyph(self, glyph: str):
746
+ if self.text() != glyph:
747
+ self.setText(glyph)
748
+
749
+ # ---------------------------------------------------- Qt 交互模式(可选)
750
+ def enterEvent(self, event):
751
+ self.hovered.emit(True)
752
+ super().enterEvent(event)
753
+
754
+ def leaveEvent(self, event):
755
+ self.hovered.emit(False)
756
+ super().leaveEvent(event)
757
+
758
+ def mousePressEvent(self, event):
759
+ if event.button() == Qt.LeftButton:
760
+ self.pressed.emit(True)
761
+ event.accept()
762
+ return
763
+ super().mousePressEvent(event)
764
+
765
+ def mouseReleaseEvent(self, event):
766
+ if event.button() == Qt.LeftButton:
767
+ inside = self.rect().contains(event.position().toPoint())
768
+ self.pressed.emit(False)
769
+ if inside:
770
+ self.clicked.emit()
771
+ event.accept()
772
+ return
773
+ super().mouseReleaseEvent(event)
774
+
775
+
776
+ class _TitleLabel(QLabel):
777
+ """会自动省略中间/末尾的标题文本(窗口变窄时显示 …)。"""
778
+
779
+ def __init__(self, text: str = "", parent=None):
780
+ super().__init__(parent)
781
+ self._full_text = ""
782
+ self.setObjectName("TitleBarTitle")
783
+ self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
784
+ self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
785
+ self.set_full_text(text)
786
+
787
+ def set_full_text(self, text: str):
788
+ self._full_text = text or ""
789
+ self._update_elide()
790
+
791
+ def full_text(self) -> str:
792
+ return self._full_text
793
+
794
+ def _update_elide(self):
795
+ available = max(0, self.width())
796
+ metrics = self.fontMetrics()
797
+ elided = metrics.elidedText(self._full_text, Qt.ElideRight, available)
798
+ if elided != self.text():
799
+ super().setText(elided)
800
+ self.setToolTip(self._full_text if elided != self._full_text else "")
801
+
802
+ def resizeEvent(self, event):
803
+ super().resizeEvent(event)
804
+ self._update_elide()
805
+
806
+ def changeEvent(self, event):
807
+ super().changeEvent(event)
808
+ if event.type() in (QEvent.FontChange, QEvent.StyleChange):
809
+ self._update_elide()
810
+
811
+
812
+ # =============================================================================
813
+ # Qt 自绘系统菜单(Windows 11 风格)
814
+ # =============================================================================
815
+
816
+ #: 菜单主题(无深色适配)
817
+ _MENU_BG = "#F9F9F9"
818
+ _MENU_BORDER = "#E8E8E8"
819
+ _MENU_SEPARATOR = "#E2E2E2"
820
+ _MENU_TEXT = "#000000"
821
+ _MENU_HOVER_BG = "#F0F0F0"
822
+ _MENU_DISABLED_TEXT = "#ABABAB"
823
+ _MENU_FONT_PX = 12
824
+ _MENU_ICON_PX = 8
825
+ #: 固定菜单宽度(逻辑像素,按 96 DPI 设计);可见面板宽度
826
+ _MENU_PANEL_WIDTH = 200
827
+ _MENU_RADIUS = 4
828
+ _MENU_OUTER_MARGIN = 8
829
+ #: 行内容/悬停高亮/分割线相对面板左右边缘的留白
830
+ _MENU_CONTENT_INSET = 8
831
+ #: fonticon 左边缘距面板左侧;描述文字左边缘距面板左侧
832
+ _MENU_ICON_LEFT = 16
833
+ _MENU_ICON_SLOT = 16
834
+ _MENU_TEXT_LEFT = 32
835
+ #: 右侧 Alt+F4 距面板右边缘
836
+ _MENU_SHORTCUT_RIGHT = 16
837
+ _MENU_ITEM_V_MARGIN = 4
838
+ _MENU_SEPARATOR_H = 1
839
+ _MENU_SHORTCUT_GAP = 16
840
+ _MENU_FADE_MS = 250
841
+ _MENU_SPACE_GAP = 16
842
+ #: 双击图标“第二次按下”的位置容差(逻辑像素)
843
+ _ICON_DOUBLE_CLICK_SLOP = 8
844
+
845
+ _MENU_SEPARATOR_KEY = "separator"
846
+
847
+
848
+ def _system_menu_position(anchor: QPoint, panel_size: QSize,
849
+ available: QRect, full: QRect, *,
850
+ margin: int = _MENU_OUTER_MARGIN,
851
+ gap: int = _MENU_SPACE_GAP) -> QPoint:
852
+ """计算系统菜单弹窗左上角(Qt 逻辑坐标)。
853
+
854
+ * 默认:可见面板左上角对准 ``anchor``;
855
+ * 下方空间不足(离任务栏/显示器下边界 < ``gap``):可见面板左下角对准
856
+ ``anchor``(菜单向上展开);
857
+ * 水平超出工作区 / 翻转后超出屏幕顶部时贴边。
858
+ """
859
+ popup_w = panel_size.width() + 2 * margin
860
+ popup_h = panel_size.height() + 2 * margin
861
+
862
+ x = anchor.x() - margin
863
+ y = anchor.y() - margin
864
+
865
+ # 任务栏可见时用工作区下边界;自动隐藏/无任务栏时用显示器下边界
866
+ boundary = (available.bottom() if available.bottom() < full.bottom()
867
+ else full.bottom())
868
+ if boundary - (anchor.y() + panel_size.height()) < gap:
869
+ y = anchor.y() - panel_size.height() - margin
870
+
871
+ # 水平贴边
872
+ if x + popup_w > available.right() + 1:
873
+ x = available.right() + 1 - popup_w
874
+ if x < available.left():
875
+ x = available.left()
876
+
877
+ # 垂直贴边(翻转后可能超出屏幕顶部)
878
+ if y < available.top():
879
+ y = available.top()
880
+ if y + popup_h > full.bottom() + 1:
881
+ y = full.bottom() + 1 - popup_h
882
+ return QPoint(x, y)
883
+
884
+
885
+ class _SystemMenuItem:
886
+ """菜单项数据(纯数据 + 弹窗本地矩形)。"""
887
+
888
+ __slots__ = ("key", "text", "glyph", "shortcut", "enabled",
889
+ "separator", "rect")
890
+
891
+ def __init__(self, key, text, glyph="", shortcut="", *,
892
+ enabled=True, separator=False):
893
+ self.key = key
894
+ self.text = text
895
+ self.glyph = glyph
896
+ self.shortcut = shortcut
897
+ self.enabled = bool(enabled)
898
+ self.separator = bool(separator)
899
+ self.rect = QRect()
900
+
901
+ @classmethod
902
+ def make_separator(cls):
903
+ return cls(_MENU_SEPARATOR_KEY, "", separator=True)
904
+
905
+
906
+ class SystemMenu(QWidget):
907
+ """Qt 自绘系统菜单(还原/移动/大小/最小化/最大化/关闭)。
908
+
909
+ 由 ``TitleBar.show_system_menu()`` 创建和弹出;也可单独实例化用于测试。
910
+ 可见面板固定 200px 宽(96 DPI 逻辑像素)、4px 圆角;行高随字体自适应。
911
+ 弹窗自身负责:定位、渐显(125ms,无渐隐)、悬停/按下、圆角阴影绘制、
912
+ 点击任意位置关闭(Qt::Popup 的全局鼠标抓取 + WA_NoMouseReplay)。
913
+ """
914
+
915
+ actionTriggered = Signal(str)
916
+ iconDoubleClicked = Signal()
917
+
918
+ def __init__(self, window=None, parent=None):
919
+ super().__init__(parent)
920
+ self._window = window
921
+ self._items = []
922
+ self._panel_size = QSize(_MENU_PANEL_WIDTH, 1)
923
+ self._text_x = _MENU_OUTER_MARGIN + _MENU_TEXT_LEFT
924
+ self._icon_x = _MENU_OUTER_MARGIN + _MENU_ICON_LEFT
925
+ self._shortcut_right = (_MENU_OUTER_MARGIN + _MENU_PANEL_WIDTH
926
+ - _MENU_SHORTCUT_RIGHT)
927
+ self._hover_index = -1
928
+ self._pressed_index = -1
929
+ self._has_press = False
930
+
931
+ # 双击图标关闭:只对“左键图标打开”的菜单生效
932
+ self._allow_icon_double_click = False
933
+ self._icon_anchor = QPoint()
934
+ self._opened_timer = QElapsedTimer()
935
+
936
+ self._fade_anim = None
937
+
938
+ self._label_font = QFont(self.font())
939
+ self._label_font.setPixelSize(_MENU_FONT_PX)
940
+ self._label_font.setWeight(QFont.Normal)
941
+ self._bold_font = QFont(self._label_font)
942
+ self._bold_font.setWeight(QFont.Bold)
943
+ self._icon_font = QFont()
944
+ self._icon_font.setFamilies(_FONT_FAMILIES)
945
+ self._icon_font.setPixelSize(_MENU_ICON_PX)
946
+ self._icon_font.setWeight(QFont.Normal)
947
+
948
+ self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint
949
+ | Qt.NoDropShadowWindowHint
950
+ | Qt.WindowDoesNotAcceptFocus)
951
+ self.setAttribute(Qt.WA_TranslucentBackground, True)
952
+ self.setAttribute(Qt.WA_ShowWithoutActivating, True)
953
+ self.setAttribute(Qt.WA_NoMouseReplay, True)
954
+ self.setFocusPolicy(Qt.NoFocus)
955
+ self.setMouseTracking(True)
956
+
957
+ # ------------------------------------------------------------------ 构建
958
+ def _rebuild(self, maximized: bool, supports_hints: bool):
959
+ glyphs = _CAPTION_GLYPHS
960
+ self._items = [
961
+ _SystemMenuItem("restore", "还原(R)", glyphs["restore"],
962
+ enabled=maximized),
963
+ _SystemMenuItem("move", "移动(M)",
964
+ enabled=(not maximized and supports_hints)),
965
+ _SystemMenuItem("size", "大小(S)",
966
+ enabled=(not maximized and supports_hints)),
967
+ _SystemMenuItem("minimize", "最小化(N)", glyphs["minimize"]),
968
+ _SystemMenuItem("maximize", "最大化(X)", glyphs["maximize"],
969
+ enabled=not maximized),
970
+ _SystemMenuItem.make_separator(),
971
+ _SystemMenuItem("close", "关闭(C)", glyphs["close"], "Alt+F4"),
972
+ ]
973
+ self._layout_items()
974
+ self._hover_index = -1
975
+ self._pressed_index = -1
976
+ self._has_press = False
977
+
978
+ def _layout_items(self):
979
+ label_fm = QFontMetrics(self._label_font)
980
+ icon_fm = QFontMetrics(self._icon_font)
981
+ row_h = max(label_fm.height(), icon_fm.height()) + 2 * _MENU_ITEM_V_MARGIN
982
+
983
+ # 宽度固定(按 96 DPI 设计),高度按行数自适应
984
+ panel_w = _MENU_PANEL_WIDTH
985
+ panel_h = 0
986
+ for item in self._items:
987
+ panel_h += (_MENU_SEPARATOR_H if item.separator else row_h)
988
+
989
+ # 放置每行(弹窗本地坐标,含 8px 阴影外边距)
990
+ y = _MENU_OUTER_MARGIN
991
+ for item in self._items:
992
+ if item.separator:
993
+ item.rect = QRect(_MENU_OUTER_MARGIN, y, panel_w,
994
+ _MENU_SEPARATOR_H)
995
+ y += _MENU_SEPARATOR_H
996
+ else:
997
+ item.rect = QRect(_MENU_OUTER_MARGIN, y, panel_w, row_h)
998
+ y += row_h
999
+
1000
+ self._panel_size = QSize(panel_w, panel_h)
1001
+ self._text_x = _MENU_OUTER_MARGIN + _MENU_TEXT_LEFT
1002
+ self._icon_x = _MENU_OUTER_MARGIN + _MENU_ICON_LEFT
1003
+ self._shortcut_right = (_MENU_OUTER_MARGIN + panel_w
1004
+ - _MENU_SHORTCUT_RIGHT)
1005
+ self.resize(panel_w + 2 * _MENU_OUTER_MARGIN,
1006
+ panel_h + 2 * _MENU_OUTER_MARGIN)
1007
+
1008
+ # ------------------------------------------------------------------ 弹出
1009
+ def panel_size(self) -> QSize:
1010
+ """可见面板尺寸(不含 8px 阴影外边距)。"""
1011
+ return QSize(self._panel_size)
1012
+
1013
+ def popup_at(self, anchor: QPoint, *, icon_press: bool = False):
1014
+ window = self._window
1015
+ if window is None:
1016
+ return
1017
+ try:
1018
+ maximized = bool(window.isMaximized())
1019
+ except RuntimeError:
1020
+ return
1021
+ helper = getattr(window, "_frame_helper", None)
1022
+ self._rebuild(maximized, _IS_WINDOWS and helper is not None)
1023
+
1024
+ anchor = QPoint(anchor)
1025
+ self._allow_icon_double_click = bool(icon_press)
1026
+ self._icon_anchor = QPoint(anchor)
1027
+ self._opened_timer.start()
1028
+
1029
+ try:
1030
+ screen = QGuiApplication.screenAt(anchor) or self._window.screen()
1031
+ except RuntimeError:
1032
+ screen = QGuiApplication.primaryScreen()
1033
+ if screen is None:
1034
+ screen = QGuiApplication.primaryScreen()
1035
+ if screen is None:
1036
+ return
1037
+ pos = _system_menu_position(anchor, self._panel_size,
1038
+ screen.availableGeometry(),
1039
+ screen.geometry())
1040
+ self.setGeometry(QRect(pos, self.size()))
1041
+ self.setWindowOpacity(0.0)
1042
+ self.show()
1043
+ self.raise_()
1044
+ self._start_fade()
1045
+ self.update()
1046
+
1047
+ def _start_fade(self):
1048
+ self._fade_anim = QPropertyAnimation(self, b"windowOpacity", self)
1049
+ self._fade_anim.setDuration(_MENU_FADE_MS)
1050
+ self._fade_anim.setStartValue(0.0)
1051
+ self._fade_anim.setEndValue(1.0)
1052
+ self._fade_anim.setEasingCurve(QEasingCurve.OutCubic)
1053
+ self._fade_anim.start()
1054
+
1055
+ def hideEvent(self, event):
1056
+ if self._fade_anim is not None:
1057
+ self._fade_anim.stop()
1058
+ self._fade_anim = None
1059
+ self.setWindowOpacity(1.0)
1060
+ self._hover_index = -1
1061
+ self._pressed_index = -1
1062
+ self._has_press = False
1063
+ self._allow_icon_double_click = False
1064
+ super().hideEvent(event)
1065
+
1066
+ # ------------------------------------------------------------------ 交互
1067
+ def _item_at(self, point: QPoint) -> int:
1068
+ for index, item in enumerate(self._items):
1069
+ if item.separator:
1070
+ continue
1071
+ if item.rect.contains(point):
1072
+ return index
1073
+ return -1
1074
+
1075
+ def mouseMoveEvent(self, event):
1076
+ index = self._item_at(event.position().toPoint())
1077
+ if index != self._hover_index:
1078
+ self._hover_index = index
1079
+ self.update()
1080
+ super().mouseMoveEvent(event)
1081
+
1082
+ def mousePressEvent(self, event):
1083
+ # 双击图标关闭:第二次按下落在菜单窗口上(位置/时间都匹配)时关窗
1084
+ if (event.button() == Qt.LeftButton
1085
+ and self._allow_icon_double_click
1086
+ and self._opened_timer.isValid()
1087
+ and self._opened_timer.elapsed() <= QApplication.doubleClickInterval()
1088
+ and (event.globalPosition().toPoint() - self._icon_anchor)
1089
+ .manhattanLength() <= _ICON_DOUBLE_CLICK_SLOP):
1090
+ self._allow_icon_double_click = False
1091
+ self.hide()
1092
+ self.iconDoubleClicked.emit()
1093
+ event.accept()
1094
+ return
1095
+
1096
+ if event.button() == Qt.RightButton:
1097
+ # 菜单里右键也视为“点击任一地方消除”
1098
+ self.hide()
1099
+ event.accept()
1100
+ return
1101
+
1102
+ if event.button() == Qt.LeftButton:
1103
+ self._pressed_index = self._item_at(event.position().toPoint())
1104
+ self._has_press = True
1105
+ self.update()
1106
+ event.accept()
1107
+ return
1108
+ super().mousePressEvent(event)
1109
+
1110
+ def mouseReleaseEvent(self, event):
1111
+ if event.button() == Qt.LeftButton and self._has_press:
1112
+ self._has_press = False
1113
+ index = self._item_at(event.position().toPoint())
1114
+ if index == self._pressed_index and index >= 0:
1115
+ item = self._items[index]
1116
+ if item.enabled:
1117
+ self.hide()
1118
+ self.actionTriggered.emit(item.key)
1119
+ event.accept()
1120
+ return
1121
+ self.hide()
1122
+ event.accept()
1123
+ return
1124
+ super().mouseReleaseEvent(event)
1125
+
1126
+ def leaveEvent(self, event):
1127
+ if self._hover_index != -1:
1128
+ self._hover_index = -1
1129
+ self.update()
1130
+ super().leaveEvent(event)
1131
+
1132
+ def keyPressEvent(self, event):
1133
+ if event.key() == Qt.Key_Escape:
1134
+ self.hide()
1135
+ event.accept()
1136
+ return
1137
+ super().keyPressEvent(event)
1138
+
1139
+ # ------------------------------------------------------------------ 绘制
1140
+ def paintEvent(self, event):
1141
+ painter = QPainter(self)
1142
+ painter.setRenderHint(QPainter.Antialiasing, True)
1143
+ painter.setRenderHint(QPainter.TextAntialiasing, True)
1144
+
1145
+ margin = _MENU_OUTER_MARGIN
1146
+ panel = QRectF(self.rect()).adjusted(margin, margin, -margin, -margin)
1147
+ path = QPainterPath()
1148
+ path.addRoundedRect(panel, _MENU_RADIUS, _MENU_RADIUS)
1149
+
1150
+ self._paint_shadow(painter, panel)
1151
+ painter.fillPath(path, QColor(_MENU_BG))
1152
+
1153
+ painter.save()
1154
+ painter.setClipPath(path)
1155
+
1156
+ for index, item in enumerate(self._items):
1157
+ if item.separator:
1158
+ y = item.rect.top() + 0.5
1159
+ painter.setPen(QPen(QColor(_MENU_SEPARATOR), 1))
1160
+ painter.drawLine(
1161
+ QPointF(panel.left() + _MENU_CONTENT_INSET, y),
1162
+ QPointF(panel.right() - _MENU_CONTENT_INSET, y))
1163
+ continue
1164
+
1165
+ if index in (self._hover_index, self._pressed_index):
1166
+ hover_rect = QRectF(item.rect).adjusted(
1167
+ _MENU_CONTENT_INSET, 0, -_MENU_CONTENT_INSET, 0)
1168
+ painter.fillRect(hover_rect, QColor(_MENU_HOVER_BG))
1169
+
1170
+ color = QColor(_MENU_TEXT if item.enabled else _MENU_DISABLED_TEXT)
1171
+ content_h = item.rect.height() - 2 * _MENU_ITEM_V_MARGIN
1172
+ is_close = item.key == "close"
1173
+
1174
+ if item.glyph:
1175
+ painter.setFont(self._icon_font)
1176
+ painter.setPen(color)
1177
+ painter.drawText(
1178
+ QRectF(self._icon_x, item.rect.top() + _MENU_ITEM_V_MARGIN,
1179
+ _MENU_ICON_SLOT, content_h),
1180
+ Qt.AlignLeft | Qt.AlignVCenter, item.glyph)
1181
+
1182
+ text_font = self._bold_font if is_close else self._label_font
1183
+ painter.setFont(text_font)
1184
+ painter.setPen(color)
1185
+ text_rect = QRectF(self._text_x, item.rect.top(),
1186
+ max(0.0, self._shortcut_right - self._text_x),
1187
+ item.rect.height())
1188
+ if item.shortcut:
1189
+ shortcut_w = QFontMetrics(self._bold_font).horizontalAdvance(
1190
+ item.shortcut)
1191
+ text_rect.setWidth(max(0.0, text_rect.width()
1192
+ - shortcut_w - _MENU_SHORTCUT_GAP))
1193
+ shortcut_rect = QRectF(self._shortcut_right - shortcut_w,
1194
+ item.rect.top(), shortcut_w,
1195
+ item.rect.height())
1196
+ painter.setFont(self._bold_font)
1197
+ painter.drawText(shortcut_rect,
1198
+ Qt.AlignRight | Qt.AlignVCenter, item.shortcut)
1199
+ painter.setFont(text_font)
1200
+ painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignVCenter,
1201
+ item.text)
1202
+
1203
+ painter.restore()
1204
+
1205
+ # 边框画在 panel 内侧半像素处,保证 1px 在 100% 缩放下不糊
1206
+ painter.setPen(QPen(QColor(_MENU_BORDER), 1))
1207
+ painter.setBrush(Qt.NoBrush)
1208
+ border_rect = panel.adjusted(0.5, 0.5, -0.5, -0.5)
1209
+ border_path = QPainterPath()
1210
+ border_path.addRoundedRect(border_rect, _MENU_RADIUS - 0.5,
1211
+ _MENU_RADIUS - 0.5)
1212
+ painter.drawPath(border_path)
1213
+
1214
+ @staticmethod
1215
+ def _paint_shadow(painter: QPainter, panel: QRectF):
1216
+ """在 8px 透明外边距内画柔和阴影(不依赖 QGraphicsEffect)。"""
1217
+ for step in range(5, 0, -1):
1218
+ alpha = 6 + (5 - step) * 4
1219
+ rect = panel.adjusted(-step, -step + 2, step, step + 2)
1220
+ shadow_path = QPainterPath()
1221
+ shadow_path.addRoundedRect(rect, _MENU_RADIUS + step,
1222
+ _MENU_RADIUS + step)
1223
+ painter.fillPath(shadow_path, QColor(0, 0, 0, alpha))
1224
+
1225
+
1226
+ class TitleBar(QWidget):
1227
+ """Windows 11 风格标题栏:图标 + 标题 + 最小化/最大化/关闭。
1228
+
1229
+ 通常不需要手动创建,直接继承 ``TitleBarWindow`` / ``TitleBarMainWindow``
1230
+ 即可;也可以把它放进 ``TitleBarMainWindow.setMenuWidget()`` 之类的位置。
1231
+
1232
+ 参数
1233
+ ----
1234
+ window:
1235
+ 所属顶层窗口。Qt 交互模式下按钮要调用窗口的最小化/最大化/关闭。
1236
+ title:
1237
+ 窗口标题,过长会自动省略。
1238
+ icon:
1239
+ 标题左侧的 16x16 图标;传 None 则不显示图标。
1240
+ height:
1241
+ 标题栏高度(逻辑像素),None(默认)表示使用类属性 ``HEIGHT``。
1242
+ 三个标题栏按钮的高度、非客户区命中测试区域都会跟着变,
1243
+ 不会出现“栏变高了、按钮碰撞箱还是旧值”的问题。
1244
+ native_caption:
1245
+ True(默认)时使用 Windows 原生非客户区命中测试:悬停高亮、
1246
+ Windows 11 贴靠布局悬浮面板都由系统提供,点击由组件转换成
1247
+ WM_SYSCOMMAND(动画仍是 DWM 原生)。False 时按钮由 Qt 处理,
1248
+ 没有贴靠布局悬浮面板。
1249
+ dark:
1250
+ 深/浅色主题;None 表示跟随系统。
1251
+
1252
+ 自定义控件
1253
+ --------
1254
+ 标题栏里放置的按钮/输入框等控件默认会被当作“拖动区”。
1255
+ 需要响应鼠标的控件请设置 ``widget.setProperty("titlebarInteractive", True)``,
1256
+ 命中测试会把该区域交回 Qt。
1257
+
1258
+ 系统菜单
1259
+ --------
1260
+ 左键按下应用图标、右键按下标题栏(CaptionButton 区域除外)或按
1261
+ ``Alt+Space`` 会弹出 Qt 自绘的系统菜单(``SystemMenu``):
1262
+ 还原/移动/大小/最小化/最大化/关闭。``移动``/``大小`` 由
1263
+ ``WindowsFrameHelper.begin_system_menu_hint()`` 实现;关闭窗口用
1264
+ ``window.close()``。也可以手动调用 ``show_system_menu()``。
1265
+ """
1266
+
1267
+ HEIGHT = _DEFAULT_TITLEBAR_HEIGHT
1268
+
1269
+ #: 深/浅色主题变化时发出(跟随系统主题切换时也会发出),
1270
+ #: 方便把窗口内容区一起换色。
1271
+ themeChanged = Signal(bool)
1272
+
1273
+ #: 标题栏高度变化时发出(参数为新高度,逻辑像素)。
1274
+ heightChanged = Signal(int)
1275
+
1276
+ def __init__(self, window=None, title: str = "", icon: Optional[QIcon] = None,
1277
+ *, height: Optional[int] = None, native_caption: bool = True,
1278
+ dark: Optional[bool] = None, parent=None):
1279
+ super().__init__(parent)
1280
+ self._window = window
1281
+ # 实例高度:显式 height 参数优先,否则取类属性 HEIGHT(子类可覆盖)。
1282
+ # 必须在 _build_layout() 之前确定,按钮会按它定尺寸。
1283
+ self._height = self._resolve_height(height)
1284
+ self._native_caption = bool(native_caption)
1285
+ self._dark = system_is_dark() if dark is None else bool(dark)
1286
+ self._active = True
1287
+ self._maximized = False
1288
+ self._hover_role = None
1289
+ self._pressed_role = None
1290
+ #: 惰性创建的自绘系统菜单
1291
+ self._system_menu = None
1292
+ self._icon_close_in_progress = False
1293
+
1294
+ self.setObjectName("TitleBar")
1295
+ self.setAttribute(Qt.WA_StyledBackground, True)
1296
+ self.setFixedHeight(self._height)
1297
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
1298
+ self.setFocusPolicy(Qt.NoFocus)
1299
+
1300
+ self._build_layout()
1301
+ self.set_icon(icon)
1302
+ self.set_title(title)
1303
+ self.set_dark(self._dark)
1304
+ self.set_active(True)
1305
+ self.set_maximized(False)
1306
+
1307
+ if not self._native_caption:
1308
+ for button in self._buttons.values():
1309
+ button.set_qt_interactive(True)
1310
+
1311
+ @classmethod
1312
+ def _resolve_height(cls, height: Optional[int]) -> int:
1313
+ value = cls.HEIGHT if height is None else int(height)
1314
+ if value <= 0:
1315
+ raise ValueError("标题栏高度必须为正整数")
1316
+ return value
1317
+
1318
+ # ------------------------------------------------------------------ 构建
1319
+ def _build_layout(self):
1320
+ self._buttons = {}
1321
+ layout = QHBoxLayout(self)
1322
+ layout.setContentsMargins(0, 0, 0, 0)
1323
+ layout.setSpacing(0)
1324
+
1325
+ # 左侧:图标 + 标题
1326
+ left = QWidget(self)
1327
+ left.setObjectName("TitleBarLeft")
1328
+ left.setAttribute(Qt.WA_StyledBackground, True)
1329
+ left_layout = QHBoxLayout(left)
1330
+ left_layout.setContentsMargins(12, 0, 0, 0)
1331
+ left_layout.setSpacing(12)
1332
+
1333
+ self._icon_label = QLabel(left)
1334
+ self._icon_label.setObjectName("TitleBarIcon")
1335
+ self._icon_label.setFixedSize(16, 16)
1336
+ self._icon_label.setAttribute(Qt.WA_TransparentForMouseEvents, True)
1337
+ self._icon_label.hide()
1338
+
1339
+ self._title_label = _TitleLabel("", left)
1340
+
1341
+ left_layout.addWidget(self._icon_label)
1342
+ left_layout.addWidget(self._title_label, 1)
1343
+ layout.addWidget(left, 1)
1344
+
1345
+ # 右侧:最小化 / 最大化 / 关闭(从左到右)
1346
+ for role in ("minimize", "maximize", "close"):
1347
+ button = CaptionButton(role, self, height=self._height)
1348
+ self._buttons[role] = button
1349
+ button.hovered.connect(
1350
+ lambda on, r=role: self._set_hover_role(r if on else None))
1351
+ button.pressed.connect(
1352
+ lambda on, r=role: self._set_pressed_role(r if on else None))
1353
+ button.clicked.connect(lambda r=role: self._on_button_clicked(r))
1354
+ layout.addWidget(button)
1355
+ self._sync_maximize_glyph()
1356
+
1357
+ # ------------------------------------------------------------------ 属性
1358
+ @property
1359
+ def buttons(self) -> dict:
1360
+ return dict(self._buttons)
1361
+
1362
+ def title_bar_height(self) -> int:
1363
+ """标题栏高度(逻辑像素)。"""
1364
+ return self._height
1365
+
1366
+ def set_title_bar_height(self, height: int):
1367
+ """运行时修改标题栏高度(按钮与命中测试区域同步更新)。
1368
+
1369
+ 注意:``TitleBarMainWindow`` 会通过 ``heightChanged`` 信号自动
1370
+ 调整标题栏几何和内容区边距;``TitleBarWindow`` 由布局自动处理。
1371
+ """
1372
+ height = int(height)
1373
+ if height <= 0:
1374
+ raise ValueError("标题栏高度必须为正整数")
1375
+ if height == self._height:
1376
+ return
1377
+ self._height = height
1378
+ self.setFixedHeight(height)
1379
+ for button in self._buttons.values():
1380
+ button.set_caption_height(height)
1381
+ layout = self.layout()
1382
+ if layout is not None:
1383
+ layout.activate()
1384
+ self.updateGeometry()
1385
+ self.heightChanged.emit(height)
1386
+
1387
+ def set_title(self, title: str):
1388
+ self._title_label.set_full_text(title or "")
1389
+
1390
+ def title(self) -> str:
1391
+ return self._title_label.full_text()
1392
+
1393
+ # ---------------------------------------------------------------- 系统菜单
1394
+ def icon_rect(self) -> QRectF:
1395
+ """应用图标在标题栏本地坐标中的矩形;没有图标时返回空矩形。"""
1396
+ label = getattr(self, "_icon_label", None)
1397
+ if label is None or label.isHidden():
1398
+ return QRectF()
1399
+ top_left = label.mapTo(self, QPoint(0, 0))
1400
+ return QRectF(top_left, label.size())
1401
+
1402
+ def show_system_menu(self, global_pos: Optional[QPoint] = None, *,
1403
+ icon_press: bool = False):
1404
+ """在 ``global_pos``(屏幕逻辑坐标)弹出系统菜单。
1405
+
1406
+ ``icon_press=True`` 表示由“左键按下图标”触发,此时菜单会额外识别
1407
+ “双击图标”的第二按并发出 ``iconDoubleClicked``(见 ``_on_icon_double_clicked``)。
1408
+ """
1409
+ window = self._window or self.window()
1410
+ if window is None:
1411
+ return
1412
+ if self._system_menu is not None and self._system_menu.isVisible():
1413
+ return
1414
+ if global_pos is None:
1415
+ global_pos = QCursor.pos()
1416
+ if self._system_menu is None:
1417
+ self._system_menu = SystemMenu(window, parent=window)
1418
+ self._system_menu.actionTriggered.connect(self._on_system_menu_action)
1419
+ self._system_menu.iconDoubleClicked.connect(self._on_icon_double_clicked)
1420
+ self._icon_close_in_progress = False
1421
+ self._set_hover_role(None)
1422
+ self._set_pressed_role(None)
1423
+ self._system_menu.popup_at(QPoint(global_pos), icon_press=icon_press)
1424
+
1425
+ def hide_system_menu(self):
1426
+ if self._system_menu is not None:
1427
+ self._system_menu.hide()
1428
+
1429
+ def system_menu_visible(self) -> bool:
1430
+ return self._system_menu is not None and self._system_menu.isVisible()
1431
+
1432
+ def _on_icon_double_clicked(self):
1433
+ """菜单收到“双击图标”的第二次按下:直接关闭窗口。"""
1434
+ if self._icon_close_in_progress:
1435
+ return
1436
+ self._icon_close_in_progress = True
1437
+ window = self._window or self.window()
1438
+ if window is not None:
1439
+ QTimer.singleShot(0, window, window.close)
1440
+
1441
+ def _on_system_menu_action(self, key: str):
1442
+ window = self._window or self.window()
1443
+ if window is None:
1444
+ return
1445
+ handler = getattr(window, "handle_system_menu_action", None)
1446
+ if callable(handler):
1447
+ try:
1448
+ if handler(key):
1449
+ return
1450
+ except Exception:
1451
+ traceback.print_exc()
1452
+ # 独立使用 TitleBar(窗口没有原生 helper)时的兜底
1453
+ if key == "restore":
1454
+ window.showNormal()
1455
+ elif key == "minimize":
1456
+ window.showMinimized()
1457
+ elif key == "maximize":
1458
+ window.showMaximized()
1459
+ elif key == "close":
1460
+ window.close()
1461
+ elif key == "move":
1462
+ handle = window.windowHandle()
1463
+ if handle is not None:
1464
+ handle.startSystemMove()
1465
+
1466
+ def set_icon(self, icon: Optional[QIcon]):
1467
+ self._icon = icon
1468
+ if icon is None or icon.isNull():
1469
+ self._icon_label.hide()
1470
+ return
1471
+ dpr = self.devicePixelRatioF() or 1.0
1472
+ try:
1473
+ pixmap = icon.pixmap(QSize(16, 16), dpr)
1474
+ except TypeError: # 极老的 Qt
1475
+ pixmap = icon.pixmap(QSize(16, 16))
1476
+ self._icon_label.setPixmap(pixmap)
1477
+ self._icon_label.show()
1478
+
1479
+ def changeEvent(self, event):
1480
+ super().changeEvent(event)
1481
+ # 窗口拖到不同缩放比例的显示器时,重新取高分图标
1482
+ dpr_event = getattr(QEvent, "DevicePixelRatioChange", None)
1483
+ if dpr_event is not None and event.type() == dpr_event:
1484
+ if self._icon is not None:
1485
+ self.set_icon(self._icon)
1486
+
1487
+ def is_dark(self) -> bool:
1488
+ return self._dark
1489
+
1490
+ def set_dark(self, dark: bool):
1491
+ dark = bool(dark)
1492
+ if dark == self._dark and self.styleSheet():
1493
+ return
1494
+ self._dark = dark
1495
+ self.setStyleSheet(_titlebar_stylesheet(dark))
1496
+ _repolish(self)
1497
+ for button in self._buttons.values():
1498
+ _repolish(button)
1499
+ self.themeChanged.emit(dark)
1500
+
1501
+ def set_active(self, active: bool):
1502
+ active = bool(active)
1503
+ if active == self._active:
1504
+ return
1505
+ self._active = active
1506
+ self._title_label.setProperty("active", active and "true" or "false")
1507
+ _repolish(self._title_label)
1508
+ for button in self._buttons.values():
1509
+ button.set_window_active(active)
1510
+
1511
+ def set_maximized(self, maximized: bool):
1512
+ maximized = bool(maximized)
1513
+ if maximized == self._maximized:
1514
+ return
1515
+ self._maximized = maximized
1516
+ self._sync_maximize_glyph()
1517
+
1518
+ def _sync_maximize_glyph(self):
1519
+ button = self._buttons.get("maximize")
1520
+ if button is None:
1521
+ return
1522
+ button.set_glyph(_CAPTION_GLYPHS["restore" if self._maximized else "maximize"])
1523
+
1524
+ # ----------------------------------------------------- 命中测试 / 按钮状态
1525
+ def hit_test(self, position: QPointF) -> Optional[str]:
1526
+ """返回点击区域类型:close / minimize / maximize / caption / client。
1527
+
1528
+ position 为标题栏本地坐标(逻辑像素);不在标题栏内返回 None。
1529
+ """
1530
+ rect = QRectF(self.rect())
1531
+ if not rect.contains(position):
1532
+ return None
1533
+
1534
+ if self._native_caption:
1535
+ role = self._button_at(position)
1536
+ if role is not None:
1537
+ return role
1538
+ else:
1539
+ if self._button_at(position) is not None:
1540
+ return "client"
1541
+
1542
+ widget = self.childAt(position.toPoint())
1543
+ while widget is not None and widget is not self:
1544
+ if widget.property("titlebarInteractive"):
1545
+ return "client"
1546
+ widget = widget.parentWidget()
1547
+
1548
+ # 其余区域:交给系统拖动 / 双击最大化 / 右键系统菜单
1549
+ return "caption"
1550
+
1551
+ def _button_at(self, position: QPointF) -> Optional[str]:
1552
+ for role, button in self._buttons.items():
1553
+ top_left = button.mapTo(self, QPoint(0, 0))
1554
+ if QRectF(top_left, button.size()).contains(position):
1555
+ return role
1556
+ return None
1557
+
1558
+ def set_native_hover(self, hit_code: Optional[int]):
1559
+ self._set_hover_role(self._role_from_hit_code(hit_code))
1560
+
1561
+ def set_native_pressed(self, hit_code: Optional[int]):
1562
+ role = self._role_from_hit_code(hit_code)
1563
+ self._set_pressed_role(role)
1564
+ if role is not None:
1565
+ self._set_hover_role(role)
1566
+
1567
+ @staticmethod
1568
+ def _role_from_hit_code(hit_code: Optional[int]) -> Optional[str]:
1569
+ return {HTMINBUTTON: "minimize",
1570
+ HTMAXBUTTON: "maximize",
1571
+ HTCLOSE: "close"}.get(hit_code)
1572
+
1573
+ def _set_hover_role(self, role: Optional[str]):
1574
+ if role == self._hover_role:
1575
+ return
1576
+ self._hover_role = role
1577
+ self._refresh_button_states()
1578
+
1579
+ def _set_pressed_role(self, role: Optional[str]):
1580
+ if role == self._pressed_role:
1581
+ return
1582
+ self._pressed_role = role
1583
+ self._refresh_button_states()
1584
+
1585
+ def _refresh_button_states(self):
1586
+ for role, button in self._buttons.items():
1587
+ if role == self._pressed_role:
1588
+ button.set_caption_state("pressed")
1589
+ elif role == self._hover_role:
1590
+ button.set_caption_state("hover")
1591
+ else:
1592
+ button.set_caption_state("normal")
1593
+
1594
+ # ------------------------------------------------------------ Qt 交互模式
1595
+ def _on_button_clicked(self, role: str):
1596
+ window = self._window or self.window()
1597
+ if window is None:
1598
+ return
1599
+ if role == "minimize":
1600
+ window.showMinimized()
1601
+ elif role == "maximize":
1602
+ if window.isMaximized():
1603
+ window.showNormal()
1604
+ else:
1605
+ window.showMaximized()
1606
+ elif role == "close":
1607
+ window.close()
1608
+
1609
+ # ------------------------------------------- Qt 交互模式的系统菜单触发
1610
+ def mousePressEvent(self, event):
1611
+ # 原生模式下这些区域走非客户区命中测试,不会到达这里;
1612
+ # 这里保证 TitleBar 单独使用(无 helper)时菜单仍然可用。
1613
+ pos = event.position()
1614
+ if event.button() == Qt.LeftButton and self.icon_rect().contains(pos):
1615
+ self.show_system_menu(event.globalPosition().toPoint(),
1616
+ icon_press=True)
1617
+ event.accept()
1618
+ return
1619
+ if event.button() == Qt.RightButton and self._button_at(pos) is None:
1620
+ self.show_system_menu(event.globalPosition().toPoint())
1621
+ event.accept()
1622
+ return
1623
+ super().mousePressEvent(event)
1624
+
1625
+ def mouseDoubleClickEvent(self, event):
1626
+ if (event.button() == Qt.LeftButton
1627
+ and self.icon_rect().contains(event.position())):
1628
+ window = self._window or self.window()
1629
+ if window is not None:
1630
+ window.close()
1631
+ event.accept()
1632
+ return
1633
+ super().mouseDoubleClickEvent(event)
1634
+
1635
+ def contextMenuEvent(self, event):
1636
+ if self._button_at(QPointF(event.pos())) is None:
1637
+ self.show_system_menu(event.globalPos())
1638
+ event.accept()
1639
+ return
1640
+ super().contextMenuEvent(event)
1641
+
1642
+
1643
+ # =============================================================================
1644
+ # 原生窗口框架助手
1645
+ # =============================================================================
1646
+
1647
+ class WindowsFrameHelper:
1648
+ """把任意 Qt 顶层窗口变成“保留 Windows 原生效果”的自定义标题栏窗口。
1649
+
1650
+ 负责:
1651
+ * 补回 WS_OVERLAPPEDWINDOW(阴影、动画、贴靠、任务栏都靠它);
1652
+ * WM_NCCALCSIZE 隐藏原生标题栏;
1653
+ * WM_NCHITTEST 把标题栏/按钮映射成系统 hit code;
1654
+ * WM_NCMOUSEMOVE / WM_NCMOUSELEAVE 维护按钮悬停;
1655
+ * WM_NCLBUTTONDOWN / WM_LBUTTONUP 自己处理按钮点击
1656
+ (不让 DefWindowProc 进入它的非客户区按钮跟踪:否则它会按标准
1657
+ 标题栏位置把原生按钮画进客户区,还会取消我们的最小化/最大化);
1658
+ * DWM 圆角与深色模式,并跟随系统主题。
1659
+ """
1660
+
1661
+ def __init__(self, window: QWidget, title_bar: TitleBar, *,
1662
+ native_caption: bool = True, dark: Optional[bool] = None,
1663
+ rounded: bool = True, auto_theme: bool = True):
1664
+ self.window = window
1665
+ self.title_bar = title_bar
1666
+ self.native_caption = bool(native_caption)
1667
+ self.rounded = bool(rounded)
1668
+ self.auto_theme = bool(auto_theme)
1669
+ self._dark = system_is_dark() if dark is None else bool(dark)
1670
+ self._hwnd = None
1671
+ self._last_maximized = None
1672
+ self._restyle_pending = False
1673
+ self._restyling = False
1674
+ # 正在按下的标题栏按钮 hit code(HTMINBUTTON / HTMAXBUTTON / HTCLOSE)
1675
+ self._pressed_hit = None
1676
+ # 原生回调里不能直接创建 Qt 弹窗:延迟 0ms 弹出系统菜单
1677
+ self._menu_open_timer = QTimer(window)
1678
+ self._menu_open_timer.setSingleShot(True)
1679
+ self._menu_open_timer.timeout.connect(self._show_pending_system_menu)
1680
+ self._pending_menu_pos = None
1681
+ self._pending_icon_press = False
1682
+ # “移动/大小”提示模式(系统菜单里选择后进入)
1683
+ self._hint_mode = None
1684
+ self._hint_edge = None
1685
+ self._hint_dragging = False
1686
+ self._hint_capture = False
1687
+ self._hint_releasing = False
1688
+ self._hint_timer = QTimer(window)
1689
+ self._hint_timer.setInterval(15)
1690
+ self._hint_timer.timeout.connect(self._tick_hint)
1691
+
1692
+ # ------------------------------------------------------------------ 安装
1693
+ def install(self):
1694
+ if not _IS_WINDOWS:
1695
+ return
1696
+ # 先创建原生窗口(此时窗口还不可见),再补回原生边框样式,
1697
+ # 避免出现“先弹出无边框窗口再变回带边框”的闪烁。
1698
+ hwnd = int(self.window.winId())
1699
+ self._hwnd = hwnd
1700
+ _inject_native_frame_styles(hwnd)
1701
+ _apply_dwm_effects(hwnd, self._dark, self.rounded)
1702
+ self.title_bar.set_dark(self._dark)
1703
+ self.title_bar.set_active(self.window.isActiveWindow())
1704
+ self.title_bar.set_maximized(self.window.isMaximized())
1705
+ self._last_maximized = bool(self.window.isMaximized())
1706
+
1707
+ def _ensure_frame_styles(self, hwnd):
1708
+ """窗口样式被 Qt 重置(setWindowFlags / 全屏切换)时,安排一次补回。
1709
+
1710
+ Qt 的 setWindowFlags()/showFullScreen() 会把窗口重新变回 WS_POPUP,
1711
+ 从而丢掉阴影和动画;这里检测到之后用 0 延迟定时器补回,
1712
+ 避免在 Qt 正在改样式的过程中重入。
1713
+ """
1714
+ if not _IS_WINDOWS:
1715
+ return
1716
+ if self._restyle_pending or self._restyling:
1717
+ return
1718
+ try:
1719
+ style = _user32.GetWindowLongPtrW(wt.HWND(hwnd), GWL_STYLE)
1720
+ except OSError:
1721
+ return
1722
+ if (style & WS_OVERLAPPEDWINDOW) == WS_OVERLAPPEDWINDOW and not (style & WS_POPUP):
1723
+ return
1724
+ self._restyle_pending = True
1725
+ QTimer.singleShot(0, self.window, self._restyle)
1726
+
1727
+ def _restyle(self):
1728
+ self._restyle_pending = False
1729
+ window = self.window
1730
+ try:
1731
+ if window.isFullScreen():
1732
+ # 全屏时保持无边框(Windows 全屏窗口本来就没有阴影)
1733
+ return
1734
+ hwnd = int(window.winId())
1735
+ except (RuntimeError, OSError):
1736
+ return
1737
+ self._restyling = True
1738
+ try:
1739
+ self._hwnd = hwnd
1740
+ _inject_native_frame_styles(hwnd)
1741
+ _apply_dwm_effects(hwnd, self._dark, self.rounded)
1742
+ except OSError:
1743
+ pass
1744
+ finally:
1745
+ self._restyling = False
1746
+
1747
+ def set_dark(self, dark: bool):
1748
+ self._dark = bool(dark)
1749
+ if self._hwnd:
1750
+ _apply_dwm_effects(self._hwnd, self._dark, self.rounded)
1751
+ self.title_bar.set_dark(self._dark)
1752
+
1753
+ def is_dark(self) -> bool:
1754
+ return self._dark
1755
+
1756
+ # -------------------------------------------------------------- 消息处理
1757
+ def handle_message(self, event_type, message):
1758
+ """由窗口的 nativeEvent() 调用;返回 (handled, result)。"""
1759
+ if not _IS_WINDOWS:
1760
+ return False, 0
1761
+ try:
1762
+ event_type = bytes(event_type)
1763
+ except TypeError:
1764
+ event_type = b""
1765
+ if event_type not in (b"windows_generic_MSG", b"windows_dispatcher_MSG"):
1766
+ return False, 0
1767
+
1768
+ try:
1769
+ msg = wt.MSG.from_address(int(message))
1770
+ except (TypeError, ValueError):
1771
+ return False, 0
1772
+
1773
+ # 原生窗口创建期间(winId() 尚未返回)不要调用 Qt API,否则会重入
1774
+ creating = self._hwnd is None
1775
+ if creating and message in (WM_SIZE, WM_NCACTIVATE, WM_NCMOUSEMOVE,
1776
+ WM_NCLBUTTONDOWN, WM_NCLBUTTONUP,
1777
+ WM_NCLBUTTONDBLCLK, WM_LBUTTONUP,
1778
+ WM_CAPTURECHANGED,
1779
+ WM_MOUSEMOVE, WM_GETMINMAXINFO,
1780
+ WM_STYLECHANGED):
1781
+ return False, 0
1782
+
1783
+ try:
1784
+ return self._process(msg)
1785
+ except Exception: # 原生回调里绝不能让异常穿回 C++,否则 Qt 会崩
1786
+ traceback.print_exc()
1787
+ return False, 0
1788
+
1789
+ def _process(self, msg):
1790
+ message = msg.message
1791
+ hwnd = msg.hWnd
1792
+
1793
+ # Qt 可能会重置原生样式(setWindowFlags / 全屏切换),发现丢失就补回
1794
+ if message in (WM_SIZE, WM_NCHITTEST, WM_NCMOUSEMOVE, WM_NCLBUTTONDOWN,
1795
+ WM_NCLBUTTONUP, WM_STYLECHANGED):
1796
+ self._ensure_frame_styles(hwnd)
1797
+
1798
+ if message == WM_STYLECHANGED:
1799
+ return False, 0
1800
+
1801
+ # 移动/大小提示模式期间优先接管鼠标/光标消息
1802
+ if self._hint_mode is not None:
1803
+ handled = self._process_hint_message(msg)
1804
+ if handled is not None:
1805
+ return handled
1806
+
1807
+ # Alt+Space:用自制菜单替换原生系统菜单
1808
+ if message == WM_SYSKEYDOWN and int(msg.wParam) == VK_SPACE:
1809
+ if int(msg.lParam) & (1 << 29): # context 位:Alt 组合键
1810
+ if not int(msg.lParam) & (1 << 30): # 忽略按住产生的重复
1811
+ self._handle_alt_space()
1812
+ return True, 0
1813
+ if (message == WM_SYSCOMMAND
1814
+ and (int(msg.wParam) & 0xFFF0) == SC_KEYMENU
1815
+ and int(msg.lParam) == VK_SPACE):
1816
+ self._handle_alt_space()
1817
+ return True, 0
1818
+
1819
+ # 右键标题栏(按钮区域除外)→ 自制菜单;同时吞掉原生菜单
1820
+ if message == WM_NCRBUTTONDOWN:
1821
+ if self._begin_caption_context_menu(hwnd, msg):
1822
+ return True, 0
1823
+ return False, 0
1824
+
1825
+ if message == WM_NCRBUTTONUP:
1826
+ if self._consume_title_bar_context(hwnd, msg.lParam):
1827
+ return True, 0
1828
+ return False, 0
1829
+
1830
+ if message == WM_CONTEXTMENU:
1831
+ if self._consume_title_bar_context(hwnd, msg.lParam):
1832
+ return True, 0
1833
+ return False, 0
1834
+
1835
+ if message == WM_NCCALCSIZE:
1836
+ if not msg.wParam:
1837
+ return False, 0
1838
+ # 非客户区算成 0:客户区铺满整个窗口(原生标题栏被“藏”起来)
1839
+ if _user32.IsZoomed(hwnd):
1840
+ # 最大化时窗口矩形比屏幕大了一圈边框,客户区应正好是工作区
1841
+ work_area = _monitor_work_area(hwnd)
1842
+ if work_area is not None:
1843
+ params = _NCCALCSIZE_PARAMS.from_address(int(msg.lParam))
1844
+ params.rgrc[0] = work_area
1845
+ return True, 0
1846
+
1847
+ if message == WM_NCHITTEST:
1848
+ hit = self._on_hit_test(hwnd, msg.lParam)
1849
+ if hit is None:
1850
+ return False, 0
1851
+ return True, int(hit)
1852
+
1853
+ if message == WM_NCMOUSEMOVE:
1854
+ self.title_bar.set_native_hover(int(msg.wParam))
1855
+ # 让窗口在鼠标离开非客户区时收到 WM_NCMOUSELEAVE
1856
+ track = _TRACKMOUSEEVENT(ctypes.sizeof(_TRACKMOUSEEVENT),
1857
+ TME_LEAVE | TME_NONCLIENT,
1858
+ hwnd, 0)
1859
+ _user32.TrackMouseEvent(ctypes.byref(track))
1860
+ return False, 0
1861
+
1862
+ if message == WM_NCMOUSELEAVE:
1863
+ if self._pressed_hit is None:
1864
+ self.title_bar.set_native_hover(None)
1865
+ self.title_bar.set_native_pressed(None)
1866
+ return False, 0
1867
+
1868
+ if message == WM_NCLBUTTONDOWN:
1869
+ if self._begin_caption_menu(hwnd, msg):
1870
+ return True, 0
1871
+ if self._begin_caption_press(hwnd, int(msg.wParam)):
1872
+ # 自己处理标题栏按钮:不能再交给 DefWindowProc。
1873
+ # 否则 DefWindowProc 会进入它自己的“非客户区按钮跟踪”循环,
1874
+ # 按标准标题栏位置把原生按钮画到我们的客户区里(就是那个
1875
+ # 按下后出现在按钮旁边的“白底按钮”),而且因为我们的按钮
1876
+ # 位置和标准位置不一致,它会在鼠标移动后取消最小化/最大化。
1877
+ return True, 0
1878
+ self.title_bar.set_native_pressed(int(msg.wParam))
1879
+ return False, 0
1880
+
1881
+ if message == WM_NCLBUTTONDBLCLK:
1882
+ if self._begin_caption_double_click(hwnd, msg):
1883
+ return True, 0
1884
+ if self._begin_caption_press(hwnd, int(msg.wParam)):
1885
+ return True, 0
1886
+ return False, 0
1887
+
1888
+ if message == WM_LBUTTONUP:
1889
+ if self._pressed_hit is not None:
1890
+ self._finish_caption_press(hwnd)
1891
+ return True, 0
1892
+ return False, 0
1893
+
1894
+ if message == WM_CAPTURECHANGED:
1895
+ if self._pressed_hit is not None:
1896
+ self._pressed_hit = None
1897
+ self.title_bar.set_native_pressed(None)
1898
+ self.title_bar.set_native_hover(None)
1899
+ return False, 0
1900
+
1901
+ if message == WM_CANCELMODE:
1902
+ if self._pressed_hit is not None:
1903
+ self._pressed_hit = None
1904
+ try:
1905
+ _user32.ReleaseCapture()
1906
+ except OSError:
1907
+ pass
1908
+ self.title_bar.set_native_pressed(None)
1909
+ self.title_bar.set_native_hover(None)
1910
+ return False, 0
1911
+
1912
+ if message == WM_NCLBUTTONUP:
1913
+ if self._pressed_hit is not None:
1914
+ self._finish_caption_press(hwnd)
1915
+ return True, 0
1916
+ self.title_bar.set_native_pressed(None)
1917
+ self.title_bar.set_native_hover(int(msg.wParam))
1918
+ return False, 0
1919
+
1920
+ if message == WM_MOUSEMOVE:
1921
+ if self._pressed_hit is not None:
1922
+ # 按住按钮拖动时:鼠标在按钮上保持按下态,移出则恢复;
1923
+ # 这些消息不再交给 Qt,避免按住标题栏按钮时内容区跟着动。
1924
+ hit = self._hit_code_at_cursor(hwnd)
1925
+ if hit == self._pressed_hit:
1926
+ self.title_bar.set_native_pressed(self._pressed_hit)
1927
+ else:
1928
+ self.title_bar.set_native_pressed(None)
1929
+ if hit in (HTMINBUTTON, HTMAXBUTTON, HTCLOSE):
1930
+ self.title_bar.set_native_hover(hit)
1931
+ else:
1932
+ self.title_bar.set_native_hover(None)
1933
+ return True, 0
1934
+ # 鼠标进入客户区,标题栏按钮不应继续保持悬停
1935
+ self.title_bar.set_native_hover(None)
1936
+ return False, 0
1937
+
1938
+ if message == WM_NCACTIVATE:
1939
+ self.title_bar.set_active(bool(msg.wParam))
1940
+ return False, 0
1941
+
1942
+ if message == WM_SIZE:
1943
+ if bool(_user32.IsIconic(hwnd)):
1944
+ # 最小化后鼠标状态失效,清掉按钮的悬停/按下残留
1945
+ self._pressed_hit = None
1946
+ self.title_bar.set_native_pressed(None)
1947
+ self.title_bar.set_native_hover(None)
1948
+ maximized = bool(_user32.IsZoomed(hwnd))
1949
+ if maximized != self._last_maximized:
1950
+ self._last_maximized = maximized
1951
+ self.title_bar.set_maximized(maximized)
1952
+ return False, 0
1953
+
1954
+ if message == WM_GETMINMAXINFO:
1955
+ self._apply_min_max_info(hwnd, msg.lParam)
1956
+ return False, 0
1957
+
1958
+ if message == WM_SETTINGCHANGE:
1959
+ self._on_setting_change(msg.lParam)
1960
+ return False, 0
1961
+
1962
+ return False, 0
1963
+
1964
+ # -------------------------------------------------------- 标题栏按钮点击
1965
+ def _begin_caption_press(self, hwnd, hit_code):
1966
+ """按下标题栏按钮:自己 SetCapture,不让 DefWindowProc 介入。
1967
+
1968
+ 这样既能避免 DefWindowProc 按“标准标题栏”位置把原生按钮画进客户区
1969
+ (按下时旁边出现的白底按钮),也能避免它用标准位置的命中测试
1970
+ 取消我们的最小化/最大化。
1971
+ """
1972
+ if hit_code not in (HTMINBUTTON, HTMAXBUTTON, HTCLOSE):
1973
+ return False
1974
+ self._pressed_hit = hit_code
1975
+ _user32.SetCapture(wt.HWND(hwnd))
1976
+ self.title_bar.set_native_pressed(hit_code)
1977
+ self.title_bar.set_native_hover(hit_code)
1978
+ return True
1979
+
1980
+ def _finish_caption_press(self, hwnd):
1981
+ pressed = self._pressed_hit
1982
+ self._pressed_hit = None
1983
+ try:
1984
+ _user32.ReleaseCapture()
1985
+ except OSError:
1986
+ pass
1987
+ self.title_bar.set_native_pressed(None)
1988
+
1989
+ # 松开时鼠标还在按钮上才执行(与系统标题栏行为一致)
1990
+ hit = self._hit_code_at_cursor(hwnd)
1991
+ if hit in (HTMINBUTTON, HTMAXBUTTON, HTCLOSE):
1992
+ self.title_bar.set_native_hover(hit)
1993
+ else:
1994
+ self.title_bar.set_native_hover(None)
1995
+ if pressed is not None and hit == pressed:
1996
+ self._perform_caption_action(hwnd, pressed)
1997
+
1998
+ def _hit_code_at_cursor(self, hwnd):
1999
+ point = wt.POINT()
2000
+ if not _user32.GetCursorPos(ctypes.byref(point)):
2001
+ return None
2002
+ lparam = ((point.y & 0xFFFF) << 16) | (point.x & 0xFFFF)
2003
+ return self._on_hit_test(hwnd, lparam)
2004
+
2005
+ def _perform_caption_action(self, hwnd, hit_code):
2006
+ """用系统命令执行按钮动作(动画/状态同步都交给 Windows 自己)。"""
2007
+ try:
2008
+ style = _user32.GetWindowLongPtrW(wt.HWND(hwnd), GWL_STYLE)
2009
+ except OSError:
2010
+ return
2011
+ if hit_code == HTCLOSE:
2012
+ if style & WS_SYSMENU:
2013
+ _user32.PostMessageW(wt.HWND(hwnd), WM_CLOSE, 0, 0)
2014
+ elif hit_code == HTMINBUTTON:
2015
+ if style & WS_MINIMIZEBOX:
2016
+ _user32.PostMessageW(wt.HWND(hwnd), WM_SYSCOMMAND, SC_MINIMIZE, 0)
2017
+ elif hit_code == HTMAXBUTTON:
2018
+ if style & WS_MAXIMIZEBOX:
2019
+ command = SC_RESTORE if _user32.IsZoomed(wt.HWND(hwnd)) else SC_MAXIMIZE
2020
+ _user32.PostMessageW(wt.HWND(hwnd), WM_SYSCOMMAND, command, 0)
2021
+
2022
+ # ------------------------------------------------------------ 系统菜单触发
2023
+ def _title_bar_local(self, hwnd, lparam):
2024
+ """屏幕坐标 lParam -> 标题栏本地坐标;不在标题栏内返回 None。"""
2025
+ title_bar = self.title_bar
2026
+ try:
2027
+ if not title_bar.isVisible():
2028
+ return None
2029
+ point = _screen_point(lparam)
2030
+ if not _user32.ScreenToClient(hwnd, ctypes.byref(point)):
2031
+ return None
2032
+ dpr = self.window.devicePixelRatioF() or 1.0
2033
+ local = QPointF(point.x / dpr, point.y / dpr)
2034
+ origin = title_bar.mapTo(self.window, QPoint(0, 0))
2035
+ rect = QRectF(origin.x(), origin.y(),
2036
+ title_bar.width(), title_bar.height())
2037
+ if not rect.contains(local):
2038
+ return None
2039
+ return local - QPointF(origin)
2040
+ except RuntimeError:
2041
+ return None
2042
+
2043
+ def _title_bar_local_at_cursor(self):
2044
+ if self._hwnd is None:
2045
+ return None
2046
+ point = wt.POINT()
2047
+ if not _user32.GetCursorPos(ctypes.byref(point)):
2048
+ return None
2049
+ lparam = ((point.y & 0xFFFF) << 16) | (point.x & 0xFFFF)
2050
+ return self._title_bar_local(self._hwnd, lparam)
2051
+
2052
+ def _open_system_menu(self, *, icon_press: bool = False, anchor=None):
2053
+ """记录锚点并用 0ms 定时器弹出菜单(避免在原生回调里造 Qt 窗口)。"""
2054
+ if not _IS_WINDOWS or self._hwnd is None:
2055
+ return
2056
+ self.cancel_system_menu_hint()
2057
+ self._pressed_hit = None
2058
+ try:
2059
+ self.title_bar.set_native_pressed(None)
2060
+ self.title_bar.set_native_hover(None)
2061
+ except RuntimeError:
2062
+ pass
2063
+ try:
2064
+ if not self.window.isActiveWindow():
2065
+ self.window.activateWindow()
2066
+ except RuntimeError:
2067
+ pass
2068
+ if anchor is None:
2069
+ anchor = QCursor.pos()
2070
+ self._pending_menu_pos = QPoint(anchor)
2071
+ self._pending_icon_press = bool(icon_press)
2072
+ self._menu_open_timer.start(0)
2073
+
2074
+ def _show_pending_system_menu(self):
2075
+ pos = self._pending_menu_pos
2076
+ if pos is None:
2077
+ return
2078
+ icon_press = self._pending_icon_press
2079
+ self._pending_menu_pos = None
2080
+ self._pending_icon_press = False
2081
+ try:
2082
+ if self.title_bar.system_menu_visible():
2083
+ return
2084
+ self.title_bar.show_system_menu(pos, icon_press=icon_press)
2085
+ except RuntimeError:
2086
+ pass
2087
+
2088
+ def _cancel_pending_system_menu(self):
2089
+ self._menu_open_timer.stop()
2090
+ self._pending_menu_pos = None
2091
+ self._pending_icon_press = False
2092
+
2093
+ def _begin_caption_menu(self, hwnd, msg):
2094
+ """左键按下应用图标:弹出系统菜单(并阻止拖动)。"""
2095
+ if self._hint_mode is not None:
2096
+ # 移动/大小模式里自己 Post 的 WM_NCLBUTTONDOWN,不能再当菜单触发
2097
+ return False
2098
+ if int(msg.wParam) != HTCAPTION:
2099
+ return False
2100
+ local = self._title_bar_local(hwnd, msg.lParam)
2101
+ if local is None:
2102
+ return False
2103
+ icon_rect = self.title_bar.icon_rect()
2104
+ if icon_rect.isEmpty() or not icon_rect.contains(local):
2105
+ return False
2106
+ self._open_system_menu(icon_press=True)
2107
+ return True
2108
+
2109
+ def _begin_caption_context_menu(self, hwnd, msg):
2110
+ """右键按下标题栏(CaptionButton 区域除外):弹出系统菜单。"""
2111
+ if self._hint_mode is not None:
2112
+ return True # 模式进行中:吞掉右键(等同取消前的保护)
2113
+ local = self._title_bar_local(hwnd, msg.lParam)
2114
+ if local is None:
2115
+ return False
2116
+ if self.title_bar._button_at(local) is not None:
2117
+ return True # 按钮区域:吞掉,不弹原生菜单
2118
+ self._open_system_menu()
2119
+ return True
2120
+
2121
+ def _consume_title_bar_context(self, hwnd, lparam):
2122
+ """吞掉标题栏上的 WM_NCRBUTTONUP / WM_CONTEXTMENU,防止原生菜单。"""
2123
+ if int(lparam) == -1: # 键盘触发
2124
+ return False
2125
+ return self._title_bar_local(hwnd, lparam) is not None
2126
+
2127
+ def _begin_caption_double_click(self, hwnd, msg):
2128
+ """双击应用图标:取消待弹菜单并关闭窗口。"""
2129
+ if self._hint_mode is not None:
2130
+ return False
2131
+ if int(msg.wParam) != HTCAPTION:
2132
+ return False
2133
+ local = self._title_bar_local(hwnd, msg.lParam)
2134
+ if local is None:
2135
+ return False
2136
+ icon_rect = self.title_bar.icon_rect()
2137
+ if icon_rect.isEmpty() or not icon_rect.contains(local):
2138
+ return False
2139
+ self._cancel_pending_system_menu()
2140
+ try:
2141
+ self.title_bar.hide_system_menu()
2142
+ except RuntimeError:
2143
+ pass
2144
+ _user32.PostMessageW(wt.HWND(hwnd), WM_CLOSE, 0, 0)
2145
+ return True
2146
+
2147
+ def _handle_alt_space(self):
2148
+ """Alt+Space:弹出自制系统菜单,锚点 = 窗口左上角。"""
2149
+ try:
2150
+ if self.title_bar.system_menu_visible():
2151
+ self.title_bar.hide_system_menu()
2152
+ self._cancel_pending_system_menu()
2153
+ return
2154
+ except RuntimeError:
2155
+ pass
2156
+ try:
2157
+ anchor = self.window.mapToGlobal(QPoint(0, 0))
2158
+ except RuntimeError:
2159
+ anchor = QCursor.pos()
2160
+ self._open_system_menu(anchor=anchor)
2161
+
2162
+ # -------------------------------------------------------- 移动/大小模式
2163
+ def begin_system_menu_hint(self, kind: str) -> bool:
2164
+ """进入“移动”或“大小”提示模式(系统菜单里选择后调用)。"""
2165
+ if not _IS_WINDOWS or self._hwnd is None:
2166
+ return False
2167
+ if kind not in ("move", "size"):
2168
+ return False
2169
+ try:
2170
+ hwnd = int(self.window.winId())
2171
+ except (RuntimeError, OSError):
2172
+ return False
2173
+ try:
2174
+ if _user32.IsZoomed(wt.HWND(hwnd)):
2175
+ return False
2176
+ except OSError:
2177
+ return False
2178
+ self.cancel_system_menu_hint()
2179
+ self._cancel_pending_system_menu()
2180
+ rect = _window_rect(hwnd)
2181
+ if rect is None:
2182
+ return False
2183
+
2184
+ self._hint_mode = kind
2185
+ self._hint_edge = None
2186
+ self._hint_dragging = False
2187
+ self._hint_releasing = False
2188
+
2189
+ if kind == "move":
2190
+ # 十字箭头热点在中心:让上箭头尖端正好落在窗口上边界
2191
+ cursor_h = _system_metrics_for_dpi(hwnd, SM_CYCURSOR) or 32
2192
+ target = QPoint((rect.left + rect.right) // 2,
2193
+ rect.top + max(1, cursor_h // 2))
2194
+ else:
2195
+ target = QPoint((rect.left + rect.right) // 2,
2196
+ (rect.top + rect.bottom) // 2)
2197
+
2198
+ try:
2199
+ _user32.SetCapture(wt.HWND(hwnd))
2200
+ # SetCapture 可能因为窗口不在前台而失败,用 GetCapture 确认
2201
+ self._hint_capture = (_user32.GetCapture() or 0) == hwnd
2202
+ except OSError:
2203
+ self._hint_capture = False
2204
+ try:
2205
+ _user32.SetCursorPos(target.x(), target.y())
2206
+ except OSError:
2207
+ # 锁屏等情况下可能移动失败;模式本身仍可继续(光标提示照常)
2208
+ pass
2209
+ self._apply_hint_cursor()
2210
+ self._hint_timer.start()
2211
+ return True
2212
+
2213
+ def cancel_system_menu_hint(self):
2214
+ """退出移动/大小模式并恢复光标(幂等)。"""
2215
+ self._hint_timer.stop()
2216
+ self._hint_mode = None
2217
+ self._hint_edge = None
2218
+ self._hint_dragging = False
2219
+ had_capture = self._hint_capture
2220
+ self._hint_capture = False
2221
+ if had_capture and _IS_WINDOWS:
2222
+ try:
2223
+ _user32.ReleaseCapture()
2224
+ except OSError:
2225
+ pass
2226
+ try:
2227
+ self.title_bar.unsetCursor()
2228
+ self.window.unsetCursor()
2229
+ except RuntimeError:
2230
+ pass
2231
+ handle = _system_cursor(IDC_ARROW)
2232
+ if handle:
2233
+ try:
2234
+ _user32.SetCursor(handle)
2235
+ except OSError:
2236
+ pass
2237
+
2238
+ def _tick_hint(self):
2239
+ if self._hint_mode is not None:
2240
+ self._apply_hint_cursor()
2241
+
2242
+ def _hint_cursor_id(self):
2243
+ if self._hint_mode == "size" and self._hint_edge:
2244
+ if self._hint_edge in ("left", "right"):
2245
+ return IDC_SIZEWE, Qt.SizeHorCursor
2246
+ return IDC_SIZENS, Qt.SizeVerCursor
2247
+ return IDC_SIZEALL, Qt.SizeAllCursor
2248
+
2249
+ def _apply_hint_cursor(self):
2250
+ if self._hint_mode is None:
2251
+ return
2252
+ cursor_id, qt_cursor = self._hint_cursor_id()
2253
+ handle = _system_cursor(cursor_id)
2254
+ if handle:
2255
+ try:
2256
+ _user32.SetCursor(handle)
2257
+ except OSError:
2258
+ pass
2259
+ try:
2260
+ self.title_bar.setCursor(qt_cursor)
2261
+ except RuntimeError:
2262
+ pass
2263
+
2264
+ def _edge_at_cursor(self):
2265
+ """大小模式:返回距离最近的那条边(严格单边,角落也不对角)。"""
2266
+ if self._hwnd is None:
2267
+ return None
2268
+ point = wt.POINT()
2269
+ if not _user32.GetCursorPos(ctypes.byref(point)):
2270
+ return None
2271
+ rect = _window_rect(self._hwnd)
2272
+ if rect is None:
2273
+ return None
2274
+ try:
2275
+ border_x, border_y = _resize_border_size(self._hwnd)
2276
+ except OSError:
2277
+ border_x = border_y = 8
2278
+
2279
+ candidates = []
2280
+ left = abs(point.x - rect.left)
2281
+ right = abs(rect.right - point.x)
2282
+ top = abs(point.y - rect.top)
2283
+ bottom = abs(rect.bottom - point.y)
2284
+ if left <= border_x:
2285
+ candidates.append((left, 0, "left"))
2286
+ if right <= border_x:
2287
+ candidates.append((right, 0, "right"))
2288
+ if top <= border_y:
2289
+ candidates.append((top, 1, "top"))
2290
+ if bottom <= border_y:
2291
+ candidates.append((bottom, 1, "bottom"))
2292
+ if not candidates:
2293
+ return None
2294
+ candidates.sort(key=lambda candidate: (candidate[0], candidate[1]))
2295
+ return candidates[0][2]
2296
+
2297
+ def _start_native_drag(self, hit_code):
2298
+ """交给 DefWindowProc 的原生拖动/缩放循环(保留 Snap/动画)。"""
2299
+ hwnd = self._hwnd
2300
+ if hwnd is None:
2301
+ return
2302
+ self._hint_dragging = True
2303
+ self._hint_releasing = True
2304
+ try:
2305
+ if self._hint_capture:
2306
+ self._hint_capture = False
2307
+ _user32.ReleaseCapture()
2308
+ except OSError:
2309
+ pass
2310
+ finally:
2311
+ self._hint_releasing = False
2312
+ point = wt.POINT()
2313
+ if not _user32.GetCursorPos(ctypes.byref(point)):
2314
+ self.cancel_system_menu_hint()
2315
+ return
2316
+ lparam = ((point.y & 0xFFFF) << 16) | (point.x & 0xFFFF)
2317
+ _user32.PostMessageW(wt.HWND(hwnd), WM_NCLBUTTONDOWN, hit_code, lparam)
2318
+
2319
+ def _process_hint_message(self, msg):
2320
+ """移动/大小模式期间优先处理消息;返回 None 表示继续走原逻辑。"""
2321
+ if self._hint_mode is None:
2322
+ return None
2323
+ message = msg.message
2324
+ if message == WM_SETCURSOR:
2325
+ self._apply_hint_cursor()
2326
+ return True, 1
2327
+ if message == WM_MOUSEMOVE:
2328
+ if self._hint_mode == "size":
2329
+ self._hint_edge = self._edge_at_cursor()
2330
+ self._apply_hint_cursor()
2331
+ return False, 0
2332
+ if message == WM_LBUTTONDOWN:
2333
+ if self._hint_mode == "move":
2334
+ if self._title_bar_local_at_cursor() is not None:
2335
+ self._start_native_drag(HTCAPTION)
2336
+ else:
2337
+ self.cancel_system_menu_hint()
2338
+ return True, 0
2339
+ edge = self._edge_at_cursor()
2340
+ self._hint_edge = edge
2341
+ hit_code = {"left": HTLEFT, "right": HTRIGHT,
2342
+ "top": HTTOP, "bottom": HTBOTTOM}.get(edge)
2343
+ if hit_code is None:
2344
+ self.cancel_system_menu_hint()
2345
+ else:
2346
+ self._start_native_drag(hit_code)
2347
+ return True, 0
2348
+ if message == WM_RBUTTONDOWN:
2349
+ self.cancel_system_menu_hint()
2350
+ return True, 0
2351
+ if message in (WM_LBUTTONUP, WM_NCLBUTTONUP):
2352
+ QTimer.singleShot(0, self.window, self.cancel_system_menu_hint)
2353
+ return False, 0
2354
+ if message == WM_CAPTURECHANGED:
2355
+ if self._hint_releasing or self._hint_dragging:
2356
+ return False, 0
2357
+ self.cancel_system_menu_hint()
2358
+ return False, 0
2359
+ if message == WM_CANCELMODE:
2360
+ self.cancel_system_menu_hint()
2361
+ return False, 0
2362
+ if message == WM_KEYDOWN and int(msg.wParam) == VK_ESCAPE:
2363
+ self.cancel_system_menu_hint()
2364
+ return True, 0
2365
+ return None
2366
+
2367
+ # ---------------------------------------------------------------- 命中测试
2368
+ def _on_hit_test(self, hwnd, lparam):
2369
+ if self.title_bar is None:
2370
+ return None
2371
+ try:
2372
+ # 注意:窗口被系统最大化时,Qt 可能会把“无边框 + 铺满屏幕”误判成
2373
+ # 全屏(windowState 里同时出现 WindowFullScreen)。用 IsZoomed
2374
+ # 区分真正的全屏,否则最大化后标题栏按钮会失去命中测试。
2375
+ if not _user32.IsZoomed(hwnd) and self.window.isFullScreen():
2376
+ return None
2377
+ except (RuntimeError, OSError):
2378
+ return None
2379
+
2380
+ hint = self._titlebar_hit_test(hwnd, lparam)
2381
+ if hint in ("minimize", "maximize", "close"):
2382
+ return {"minimize": HTMINBUTTON,
2383
+ "maximize": HTMAXBUTTON,
2384
+ "close": HTCLOSE}[hint]
2385
+ if hint == "client":
2386
+ # 交给 Qt 处理(Qt 交互模式的按钮、标题栏里的自定义控件)
2387
+ return None
2388
+
2389
+ border = self._resize_border_hit_test(hwnd, lparam)
2390
+ if border is not None:
2391
+ return border
2392
+ if hint == "caption":
2393
+ return HTCAPTION
2394
+ return None
2395
+
2396
+ def _titlebar_hit_test(self, hwnd, lparam):
2397
+ local = self._title_bar_local(hwnd, lparam)
2398
+ if local is None:
2399
+ return None
2400
+ return self.title_bar.hit_test(local)
2401
+
2402
+ def _resize_border_hit_test(self, hwnd, lparam):
2403
+ """最大化时没有可拖拽边框;否则按系统边框厚度判断 8 个方向。"""
2404
+ try:
2405
+ if _user32.IsZoomed(hwnd):
2406
+ return None
2407
+ except OSError:
2408
+ return None
2409
+ point = _screen_point(lparam)
2410
+ if not _user32.ScreenToClient(hwnd, ctypes.byref(point)):
2411
+ return None
2412
+ client = wt.RECT()
2413
+ if not _user32.GetClientRect(hwnd, ctypes.byref(client)):
2414
+ return None
2415
+ width, height = client.right - client.left, client.bottom - client.top
2416
+ border_x, border_y = _resize_border_size(hwnd)
2417
+ x, y = point.x, point.y
2418
+ if not (0 <= x < width and 0 <= y < height):
2419
+ return None
2420
+ left = x < border_x
2421
+ right = x >= width - border_x
2422
+ top = y < border_y
2423
+ bottom = y >= height - border_y
2424
+ if top and left:
2425
+ return HTTOPLEFT
2426
+ if top and right:
2427
+ return HTTOPRIGHT
2428
+ if bottom and left:
2429
+ return HTBOTTOMLEFT
2430
+ if bottom and right:
2431
+ return HTBOTTOMRIGHT
2432
+ if left:
2433
+ return HTLEFT
2434
+ if right:
2435
+ return HTRIGHT
2436
+ if top:
2437
+ return HTTOP
2438
+ if bottom:
2439
+ return HTBOTTOM
2440
+ return None
2441
+
2442
+ # --------------------------------------------------------------- 其他消息
2443
+ def _apply_min_max_info(self, hwnd, lparam):
2444
+ """把 QWidget 的 minimumSize / maximumSize 同步给系统的拖拽缩放。"""
2445
+ try:
2446
+ info = _MINMAXINFO.from_address(int(lparam))
2447
+ dpi = 96
2448
+ if _HAS_DPI_API:
2449
+ dpi = _user32.GetDpiForWindow(hwnd) or 96
2450
+ scale = dpi / 96.0
2451
+ minimum = self.window.minimumSize()
2452
+ maximum = self.window.maximumSize()
2453
+ if minimum.width() > 0:
2454
+ info.ptMinTrackSize.x = int(round(minimum.width() * scale))
2455
+ if minimum.height() > 0:
2456
+ info.ptMinTrackSize.y = int(round(minimum.height() * scale))
2457
+ if 0 < maximum.width() < QWIDGETSIZE_MAX:
2458
+ info.ptMaxTrackSize.x = int(round(maximum.width() * scale))
2459
+ if 0 < maximum.height() < QWIDGETSIZE_MAX:
2460
+ info.ptMaxTrackSize.y = int(round(maximum.height() * scale))
2461
+ except (RuntimeError, OSError):
2462
+ pass
2463
+
2464
+ def _on_setting_change(self, lparam):
2465
+ if not self.auto_theme or not lparam:
2466
+ return
2467
+ try:
2468
+ text = ctypes.wstring_at(lparam)
2469
+ except (ValueError, OSError):
2470
+ return
2471
+ if text and "ImmersiveColorSet" in text:
2472
+ dark = system_is_dark()
2473
+ if dark != self._dark:
2474
+ self.set_dark(dark)
2475
+
2476
+
2477
+ class _WindowsFrameMixin:
2478
+ """为窗口类提供 nativeEvent 转发(子类在 __init__ 里创建 self._frame_helper)。"""
2479
+
2480
+ _frame_helper: Optional[WindowsFrameHelper] = None
2481
+
2482
+ def nativeEvent(self, eventType, message):
2483
+ helper = getattr(self, "_frame_helper", None)
2484
+ if helper is not None:
2485
+ handled, result = helper.handle_message(eventType, message)
2486
+ if handled:
2487
+ return True, result
2488
+ return super().nativeEvent(eventType, message)
2489
+
2490
+ def handle_system_menu_action(self, key: str) -> bool:
2491
+ """系统菜单动作路由:move/size 交给原生 helper,其余走 Qt 窗口方法。"""
2492
+ if key in ("move", "size"):
2493
+ helper = getattr(self, "_frame_helper", None)
2494
+ if helper is not None:
2495
+ return helper.begin_system_menu_hint(key)
2496
+ return False
2497
+ if key == "restore":
2498
+ self.showNormal()
2499
+ elif key == "minimize":
2500
+ self.showMinimized()
2501
+ elif key == "maximize":
2502
+ self.showMaximized()
2503
+ elif key == "close":
2504
+ self.close()
2505
+ else:
2506
+ return False
2507
+ return True
2508
+
2509
+ def hideEvent(self, event):
2510
+ # 窗口隐藏/最小化/关闭时清理移动/大小模式(避免光标卡在十字箭头)
2511
+ helper = getattr(self, "_frame_helper", None)
2512
+ if helper is not None:
2513
+ try:
2514
+ helper.cancel_system_menu_hint()
2515
+ helper._cancel_pending_system_menu()
2516
+ except Exception:
2517
+ traceback.print_exc()
2518
+ title_bar = getattr(self, "_title_bar", None)
2519
+ if title_bar is not None:
2520
+ try:
2521
+ title_bar.hide_system_menu()
2522
+ except RuntimeError:
2523
+ pass
2524
+ super().hideEvent(event)
2525
+
2526
+
2527
+ class TitleBarWindow(_WindowsFrameMixin, QWidget):
2528
+ """自带 Windows 11 风格标题栏的顶层窗口(QWidget 版)。
2529
+
2530
+ 内容放进 ``self.body``(它自带一个空的 QVBoxLayout,直接用即可)::
2531
+
2532
+ class MyWindow(TitleBarWindow):
2533
+ def __init__(self):
2534
+ super().__init__(title="我的应用", height=40)
2535
+ self.body_layout.addWidget(QTextEdit())
2536
+
2537
+ 标题栏高度用 ``height=`` 指定(逻辑像素,默认取 ``HEIGHT``),运行时想改就调用
2538
+ ``set_title_bar_height()``;布局会自动重排,不需要手动设置固定尺寸。
2539
+
2540
+ 原生效果:窗口阴影、圆角、最小化/最大化动画、Aero Snap、Win+方向键、
2541
+ 双击标题栏最大化、Qt 自绘系统菜单(左键图标 / 右键标题栏 / Alt+Space)、
2542
+ Windows 11 贴靠布局悬浮面板。双击应用图标可直接关闭窗口。
2543
+ """
2544
+
2545
+ def __init__(self, title: str = "", icon: Optional[QIcon] = None,
2546
+ parent=None, *, height: Optional[int] = None,
2547
+ native_caption: bool = True,
2548
+ dark: Optional[bool] = None, auto_theme: bool = True,
2549
+ rounded: bool = True):
2550
+ # 非 Windows 平台没有原生无边框拖动逻辑,保留系统边框以免窗口不可操作
2551
+ flags = Qt.Window
2552
+ if _IS_WINDOWS:
2553
+ flags |= Qt.FramelessWindowHint
2554
+ super().__init__(parent, flags)
2555
+
2556
+ self._build_title_bar(title, icon, native_caption, dark, height)
2557
+ self._frame_helper = WindowsFrameHelper(
2558
+ self, self._title_bar, native_caption=native_caption,
2559
+ dark=dark, rounded=rounded, auto_theme=auto_theme)
2560
+ self._frame_helper.install()
2561
+
2562
+ # ------------------------------------------------------------------ 构建
2563
+ def _build_title_bar(self, title, icon, native_caption, dark, height=None):
2564
+ self._title_bar = TitleBar(self, title=title, icon=icon, height=height,
2565
+ native_caption=native_caption, dark=dark)
2566
+ self.setWindowTitle(title or "")
2567
+ if icon is not None:
2568
+ self.setWindowIcon(icon)
2569
+
2570
+ self.body = QWidget(self)
2571
+ self.body.setObjectName("TitleBarWindowBody")
2572
+ self.body.setAttribute(Qt.WA_StyledBackground, True)
2573
+ self.body_layout = QVBoxLayout(self.body)
2574
+ self.body_layout.setContentsMargins(0, 0, 0, 0)
2575
+ self.body_layout.setSpacing(0)
2576
+
2577
+ layout = QVBoxLayout(self)
2578
+ layout.setContentsMargins(0, 0, 0, 0)
2579
+ layout.setSpacing(0)
2580
+ layout.addWidget(self._title_bar)
2581
+ layout.addWidget(self.body, 1)
2582
+ if not _IS_WINDOWS:
2583
+ # 非 Windows 平台保留系统标题栏,隐藏自绘标题栏以免出现两条
2584
+ self._title_bar.hide()
2585
+
2586
+ # ------------------------------------------------------------------ 属性
2587
+ @property
2588
+ def title_bar(self) -> TitleBar:
2589
+ return self._title_bar
2590
+
2591
+ def set_title_bar_height(self, height: int):
2592
+ """修改标题栏高度;布局会自动重排并同步按钮命中区域。"""
2593
+ self._title_bar.set_title_bar_height(height)
2594
+
2595
+ def title_bar_height(self) -> int:
2596
+ return self._title_bar.title_bar_height()
2597
+
2598
+ def set_title(self, title: str):
2599
+ self._title_bar.set_title(title)
2600
+ self.setWindowTitle(title)
2601
+
2602
+ def set_title_bar_icon(self, icon: Optional[QIcon]):
2603
+ self._title_bar.set_icon(icon)
2604
+
2605
+ def set_dark(self, dark: bool):
2606
+ if self._frame_helper is not None:
2607
+ self._frame_helper.set_dark(dark)
2608
+ else:
2609
+ self._title_bar.set_dark(dark)
2610
+
2611
+ def is_dark(self) -> bool:
2612
+ return self._title_bar.is_dark()
2613
+
2614
+
2615
+ class TitleBarMainWindow(_WindowsFrameMixin, QMainWindow):
2616
+ """自带自定义标题栏的 QMainWindow。
2617
+
2618
+ 标题栏固定在窗口最顶端,通过 ``setContentsMargins()`` 为它留出高度,
2619
+ 所以 ``menuBar()`` / ``statusBar()`` / dock 等 QMainWindow 功能都能照常
2620
+ 使用(菜单栏会显示在标题栏下方)。标题栏高度用 ``height=`` 指定
2621
+ (逻辑像素,默认取 ``HEIGHT``),之后也可以通过 ``set_title_bar_height()`` 修改,
2622
+ 标题栏几何与内容区边距会一起同步。
2623
+ """
2624
+
2625
+ def __init__(self, title: str = "", icon: Optional[QIcon] = None,
2626
+ parent=None, *, height: Optional[int] = None,
2627
+ native_caption: bool = True,
2628
+ dark: Optional[bool] = None, auto_theme: bool = True,
2629
+ rounded: bool = True):
2630
+ flags = Qt.Window
2631
+ if _IS_WINDOWS:
2632
+ flags |= Qt.FramelessWindowHint
2633
+ super().__init__(parent, flags)
2634
+
2635
+ self._title_bar = TitleBar(self, title=title, icon=icon, height=height,
2636
+ native_caption=native_caption, dark=dark,
2637
+ parent=self)
2638
+ bar_height = self._title_bar.title_bar_height()
2639
+ self._title_bar.setGeometry(0, 0, self.width(), bar_height)
2640
+ self._title_bar.raise_()
2641
+ self.setContentsMargins(0, bar_height, 0, 0)
2642
+ self._title_bar.heightChanged.connect(self._on_title_bar_height_changed)
2643
+ self.setWindowTitle(title or "")
2644
+ if icon is not None:
2645
+ self.setWindowIcon(icon)
2646
+ if not _IS_WINDOWS:
2647
+ # 非 Windows 平台保留系统标题栏,隐藏自绘标题栏以免出现两条
2648
+ self._title_bar.hide()
2649
+ self.setContentsMargins(0, 0, 0, 0)
2650
+
2651
+ self._frame_helper = WindowsFrameHelper(
2652
+ self, self._title_bar, native_caption=native_caption,
2653
+ dark=dark, rounded=rounded, auto_theme=auto_theme)
2654
+ self._frame_helper.install()
2655
+
2656
+ def _on_title_bar_height_changed(self, height: int):
2657
+ """标题栏高度变化:重新确定标题栏几何,并为内容区留出同样高度。"""
2658
+ title_bar = getattr(self, "_title_bar", None)
2659
+ if title_bar is None or title_bar.isHidden():
2660
+ return
2661
+ title_bar.setGeometry(0, 0, self.width(), height)
2662
+ title_bar.raise_()
2663
+ self.setContentsMargins(0, height, 0, 0)
2664
+
2665
+ def resizeEvent(self, event):
2666
+ super().resizeEvent(event)
2667
+ title_bar = getattr(self, "_title_bar", None)
2668
+ if title_bar is not None:
2669
+ title_bar.setGeometry(0, 0, self.width(),
2670
+ title_bar.title_bar_height())
2671
+ title_bar.raise_()
2672
+
2673
+ @property
2674
+ def title_bar(self) -> TitleBar:
2675
+ return self._title_bar
2676
+
2677
+ def set_title_bar_height(self, height: int):
2678
+ """修改标题栏高度;标题栏几何与内容区边距会一起同步。"""
2679
+ self._title_bar.set_title_bar_height(height)
2680
+
2681
+ def title_bar_height(self) -> int:
2682
+ return self._title_bar.title_bar_height()
2683
+
2684
+ def set_title(self, title: str):
2685
+ self._title_bar.set_title(title)
2686
+ self.setWindowTitle(title)
2687
+
2688
+ def set_title_bar_icon(self, icon: Optional[QIcon]):
2689
+ self._title_bar.set_icon(icon)
2690
+
2691
+ def set_dark(self, dark: bool):
2692
+ if self._frame_helper is not None:
2693
+ self._frame_helper.set_dark(dark)
2694
+ else:
2695
+ self._title_bar.set_dark(dark)
2696
+
2697
+ def is_dark(self) -> bool:
2698
+ return self._title_bar.is_dark()
2699
+
2700
+
2701
+ # =============================================================================
2702
+ # 演示程序
2703
+ # =============================================================================
2704
+
2705
+ def _demo_icon() -> QIcon:
2706
+ """画一个简单的 16x16 应用图标(蓝色圆角方块 + 白色窗口图案)。"""
2707
+ pixmap = QPixmap(64, 64)
2708
+ pixmap.fill(Qt.transparent)
2709
+ painter = QPainter(pixmap)
2710
+ try:
2711
+ painter.setRenderHint(QPainter.Antialiasing, True)
2712
+ gradient = QLinearGradient(0, 0, 64, 64)
2713
+ gradient.setColorAt(0.0, QColor("#4CC2FF"))
2714
+ gradient.setColorAt(1.0, QColor("#0067C0"))
2715
+ painter.setPen(Qt.NoPen)
2716
+ painter.setBrush(QBrush(gradient))
2717
+ painter.drawRoundedRect(QRectF(2, 2, 60, 60), 14, 14)
2718
+
2719
+ painter.setBrush(QColor(255, 255, 255, 235))
2720
+ painter.drawRoundedRect(QRectF(16, 16, 32, 32), 5, 5)
2721
+ painter.setBrush(QColor("#0067C0"))
2722
+ painter.drawRoundedRect(QRectF(21, 21, 22, 7), 2, 2)
2723
+ finally:
2724
+ painter.end()
2725
+ return QIcon(pixmap)
2726
+
2727
+
2728
+ def _demo_body_stylesheet(dark: bool) -> str:
2729
+ if dark:
2730
+ bg, fg, muted, panel = "#191919", "#F5F5F5", "#A0A0A0", "#242424"
2731
+ else:
2732
+ bg, fg, muted, panel = "#FAFAFA", "#1A1A1A", "#5D5D5D", "#FFFFFF"
2733
+ return f"""
2734
+ QWidget#DemoBody {{
2735
+ background-color: {bg};
2736
+ }}
2737
+ QLabel#DemoHeading {{
2738
+ color: {fg};
2739
+ font-size: 26px;
2740
+ font-family: "Segoe UI Variable Display", "Segoe UI";
2741
+ }}
2742
+ QLabel#DemoText {{
2743
+ color: {muted};
2744
+ font-size: 13px;
2745
+ font-family: "Segoe UI Variable Text", "Segoe UI";
2746
+ }}
2747
+ QTextEdit#DemoTextEdit {{
2748
+ background-color: {panel};
2749
+ color: {fg};
2750
+ border: 1px solid {"#3A3A3A" if dark else "#E3E3E3"};
2751
+ border-radius: 8px;
2752
+ padding: 8px;
2753
+ font-size: 13px;
2754
+ font-family: "Segoe UI Variable Text", "Segoe UI";
2755
+ }}
2756
+ QPushButton {{
2757
+ background-color: {panel};
2758
+ color: {fg};
2759
+ border: 1px solid {"#3A3A3A" if dark else "#DADADA"};
2760
+ border-radius: 6px;
2761
+ padding: 7px 16px;
2762
+ font-size: 13px;
2763
+ font-family: "Segoe UI Variable Text", "Segoe UI";
2764
+ }}
2765
+ QPushButton:hover {{
2766
+ background-color: {"#2E2E2E" if dark else "#F0F0F0"};
2767
+ }}
2768
+ QPushButton:pressed {{
2769
+ background-color: {"#3A3A3A" if dark else "#E2E2E2"};
2770
+ }}
2771
+ """
2772
+
2773
+
2774
+ class DemoWindow(TitleBarWindow):
2775
+ def __init__(self):
2776
+ super().__init__(title="自定义标题栏 · Windows 11 效果演示", icon=_demo_icon())
2777
+ self.setMinimumSize(560, 360)
2778
+ self.resize(1000, 660)
2779
+ # 标题栏跟随系统深/浅色时,内容区也一起换色
2780
+ self.title_bar.themeChanged.connect(self._on_theme_changed)
2781
+ self._init_content()
2782
+ self._apply_theme(self.is_dark())
2783
+
2784
+ def _init_content(self):
2785
+ self.body.setObjectName("DemoBody")
2786
+ layout = self.body_layout
2787
+ layout.setContentsMargins(28, 24, 28, 24)
2788
+ layout.setSpacing(14)
2789
+
2790
+ heading = QLabel("PySide6 自定义标题栏")
2791
+ heading.setObjectName("DemoHeading")
2792
+ layout.addWidget(heading)
2793
+
2794
+ description = QLabel(
2795
+ "原生窗口效果全部保留:\n"
2796
+ "· 窗口阴影 / Windows 11 圆角\n"
2797
+ "· 最小化、最大化、还原的 DWM 动画\n"
2798
+ "· 拖动标题栏移动窗口,双击最大化/还原\n"
2799
+ "· 左键图标 / 右键标题栏 / Alt+Space 弹出系统菜单\n"
2800
+ "· 双击应用图标可直接关闭窗口\n"
2801
+ "· Aero Snap / Win+方向键贴靠\n"
2802
+ "· 鼠标悬停最大化按钮出现 Windows 11 贴靠布局\n"
2803
+ "· 标题栏高度可配置(height=…,按钮命中区域同步)")
2804
+ description.setObjectName("DemoText")
2805
+ description.setWordWrap(True)
2806
+ layout.addWidget(description)
2807
+
2808
+ self._text_edit = QTextEdit(self.body)
2809
+ self._text_edit.setObjectName("DemoTextEdit")
2810
+ self._text_edit.setReadOnly(True)
2811
+ self._text_edit.setPlainText(
2812
+ "标题栏的按钮走 Windows 原生非客户区命中测试:\n"
2813
+ " WM_NCHITTEST -> HTMINBUTTON / HTMAXBUTTON / HTCLOSE\n"
2814
+ "悬停时系统会自动提供 Windows 11 贴靠布局;\n"
2815
+ "点击由组件自己 SetCapture + WM_SYSCOMMAND 触发,\n"
2816
+ "既保证最小化/最大化可靠,也避免 DefWindowProc 把标准\n"
2817
+ "标题栏按钮画进客户区(按下时按钮旁边出现白底按钮)。\n\n"
2818
+ "窗口本身保留了 WS_OVERLAPPEDWINDOW 样式,DWM 因此会提供\n"
2819
+ "阴影、圆角、最小化/最大化动画、任务栏预览与 Aero Snap。\n\n"
2820
+ "标题栏高度在构造时用 height=40 指定;运行时调用\n"
2821
+ "set_title_bar_height() 也会同步按钮尺寸与命中测试区域。\n\n"
2822
+ "系统菜单由 Qt 自绘(圆角/阴影/渐显),支持还原、移动、大小、\n"
2823
+ "最小化、最大化、关闭;移动/大小会进入对应的光标提示模式。")
2824
+ layout.addWidget(self._text_edit, 1)
2825
+
2826
+ buttons = QHBoxLayout()
2827
+ buttons.setSpacing(8)
2828
+ minimize_btn = QPushButton("最小化")
2829
+ minimize_btn.clicked.connect(self.showMinimized)
2830
+ maximize_btn = QPushButton("最大化 / 还原")
2831
+ maximize_btn.clicked.connect(
2832
+ lambda: self.showNormal() if self.isMaximized() else self.showMaximized())
2833
+ close_btn = QPushButton("关闭")
2834
+ close_btn.clicked.connect(self.close)
2835
+ self._theme_btn = QPushButton("切换深/浅色")
2836
+ self._theme_btn.clicked.connect(self._toggle_theme)
2837
+ self._height_btn = QPushButton(f"标题栏高度:{self.title_bar_height()}")
2838
+ self._height_btn.clicked.connect(self._cycle_title_bar_height)
2839
+ for button in (minimize_btn, maximize_btn, close_btn, self._theme_btn,
2840
+ self._height_btn):
2841
+ buttons.addWidget(button)
2842
+ buttons.addStretch(1)
2843
+ layout.addLayout(buttons)
2844
+
2845
+ self._status = QLabel("")
2846
+ self._status.setObjectName("DemoText")
2847
+ layout.addWidget(self._status)
2848
+ self._update_status()
2849
+
2850
+ def _apply_theme(self, dark: bool):
2851
+ self.body.setStyleSheet(_demo_body_stylesheet(dark))
2852
+ self.set_dark(dark)
2853
+ if hasattr(self, "_theme_btn"):
2854
+ self._theme_btn.setText("切换为浅色" if dark else "切换为深色")
2855
+
2856
+ def _on_theme_changed(self, dark: bool):
2857
+ self.body.setStyleSheet(_demo_body_stylesheet(dark))
2858
+ if hasattr(self, "_theme_btn"):
2859
+ self._theme_btn.setText("切换为浅色" if dark else "切换为深色")
2860
+
2861
+ def _toggle_theme(self):
2862
+ self._apply_theme(not self.is_dark())
2863
+
2864
+ #: 演示里循环切换的标题栏高度(逻辑像素)
2865
+ _DEMO_HEIGHTS = (32, 40, 48, 56)
2866
+
2867
+ def _cycle_title_bar_height(self):
2868
+ """演示运行时改高度:按钮/命中区域会一起变。"""
2869
+ heights = self._DEMO_HEIGHTS
2870
+ try:
2871
+ index = heights.index(self.title_bar_height())
2872
+ except ValueError:
2873
+ index = 0
2874
+ height = heights[(index + 1) % len(heights)]
2875
+ self.set_title_bar_height(height)
2876
+ if hasattr(self, "_height_btn"):
2877
+ self._height_btn.setText(f"标题栏高度:{height}")
2878
+
2879
+ def _update_status(self):
2880
+ state = "最大化" if self.isMaximized() else "窗口化"
2881
+ self._status.setText(
2882
+ f"窗口状态:{state} 标题栏高度:{self.title_bar_height()} "
2883
+ "(拖动标题栏 / 双击标题栏试试)")
2884
+
2885
+ def changeEvent(self, event):
2886
+ super().changeEvent(event)
2887
+ if event.type() == QEvent.WindowStateChange and hasattr(self, "_status"):
2888
+ self._update_status()
2889
+
2890
+
2891
+ def main():
2892
+ app = QApplication(sys.argv)
2893
+ app.setApplicationName("PySide6 Custom Titlebar")
2894
+ window = DemoWindow()
2895
+ window.show()
2896
+ sys.exit(app.exec())
2897
+
2898
+
2899
+ if __name__ == "__main__":
2900
+ main()