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_navview.py ADDED
@@ -0,0 +1,4334 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """PySide6 NavigationView:统一托管窗口加载的侧边栏导航组件。
4
+
5
+ 特性:
6
+ - 顶层窗口:``class DemoWindow(NavigationView)`` 即可使用。
7
+ - 菜单项 / 折叠菜单 / 分隔描述符 / 1px 分隔线 / 内置设置项。
8
+ - Auto / Expanded / Compact / ForceCompact / Hide 五种形态,内联与 Flyout 两种展开方式。
9
+ - 250ms 侧边栏展开收起、折叠箭头、内容上滑、指示器两段位移、胶囊滚动条。
10
+ - 指示器 2×16,贴着菜单行左边界,与 fonticon 间距 10px。
11
+ - 内容区使用 glasskit 的 ``BUBBLE_BACKGROUND_NORMAL`` 全窗口玻璃。
12
+ - 侧边栏与 Flyout 使用 glasskit 玻璃材质;窄模式子菜单独立浮窗(220px 宽、
13
+ 内容 212px、8px 圆角),左边界位于主窗口左边界右侧 50px,并随主窗口移动。
14
+ - 配色、圆角、指示器尺寸可通过 ``NavigationView`` 的 ``qproperty-*`` 覆盖,
15
+ 默认值集中在 :data:`THEME_DEFAULTS`;Demo 的 QSS 见 :data:`DEMO_QSS`。
16
+
17
+ 依赖:PySide6、expander.py(图标控件)、glasskit/(玻璃材质)。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ import sys
24
+ import time
25
+ import warnings
26
+ from enum import Enum, auto
27
+ from typing import Callable
28
+
29
+ from PySide6.QtCore import (
30
+ QAbstractAnimation,
31
+ QEasingCurve,
32
+ QEvent,
33
+ QObject,
34
+ QPoint,
35
+ QPointF,
36
+ QPropertyAnimation,
37
+ QParallelAnimationGroup,
38
+ QRect,
39
+ QRectF,
40
+ QSize,
41
+ Qt,
42
+ QTimer,
43
+ QVariantAnimation,
44
+ Property,
45
+ Signal,
46
+ )
47
+ from PySide6.QtGui import (
48
+ QColor,
49
+ QFont,
50
+ QFontMetrics,
51
+ QImage,
52
+ QPainter,
53
+ QPainterPath,
54
+ QPen,
55
+ QPixmap,
56
+ )
57
+ from PySide6.QtWidgets import (
58
+ QAbstractButton,
59
+ QApplication,
60
+ QFrame,
61
+ QLabel,
62
+ QLayout,
63
+ QScrollArea,
64
+ QScrollBar,
65
+ QSizePolicy,
66
+ QVBoxLayout,
67
+ QWidget,
68
+ )
69
+
70
+ from expander import FontIcon, fonticon
71
+ from glasskit import (
72
+ BUBBLE_BACKGROUND_NORMAL,
73
+ BUBBLE_SIDEBAR_NORMAL,
74
+ FrostStyle,
75
+ blur_qimage,
76
+ paint_frost_overlay,
77
+ rounded_path,
78
+ )
79
+ from glasskit.glass import GlassWindowBase
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # 常量
84
+ # ---------------------------------------------------------------------------
85
+
86
+ APP_FONT = "Microsoft YaHei UI"
87
+
88
+ COMPACT_WIDTH = 48
89
+ EXPANDED_WIDTH = 312
90
+ ROW_HEIGHT = 36
91
+ ROW_MARGIN_X = 4
92
+ ROW_MARGIN_TOP = 4
93
+ TOP_INDENT = 16
94
+ CHILD_INDENT = 36
95
+
96
+ ICON_SIZE = 16
97
+ ICON_GAP = 18
98
+ TEXT_SIZE = 14
99
+ ARROW_SIZE = 12
100
+ ARROW_GLYPH = "\uE70D"
101
+
102
+ SEPARATOR_COLOR = "#cccccc"
103
+ SEPARATOR_SIZE = 12
104
+ SEPARATOR_TOP = 14
105
+ SEPARATOR_BOTTOM = 12
106
+
107
+ INDICATOR_COLOR = "#99ebff"
108
+ INDICATOR_W = 2
109
+ INDICATOR_H = 16
110
+ INDICATOR_GAP = 10
111
+ #: 指示器左边界贴着菜单行(按钮)左边界;菜单行左边距即 ROW_MARGIN_X。
112
+ INDICATOR_INSET = ROW_MARGIN_X
113
+ #: 图标相对按钮左边界的位置 = 指示器宽度 + 与 fonticon 的 10px 间距。
114
+ ROW_ICON_INDENT = INDICATOR_W + INDICATOR_GAP
115
+
116
+ ANIM_MS = 250
117
+ INDICATOR_STEP_MS = 125
118
+ SCROLLBAR_MS = 250
119
+ SCROLLBAR_MIN = 2
120
+ SCROLLBAR_MAX = 5
121
+ SCROLLBAR_HIT = 6
122
+ SCROLLBAR_COLOR = "#9f9f9f"
123
+
124
+ SELECTED_BG = "#2d2d2d"
125
+ SELECTED_HOVER_BG = "#292929"
126
+ SELECTED_PRESSED_BG = "#2d2d2d"
127
+ HOVER_BG = "#2d2d2d"
128
+ PRESSED_BG = "#292929"
129
+ TEXT_COLOR = "#ffffff"
130
+ PRESSED_TEXT = "#cfcfcf"
131
+
132
+ CORNER_RADIUS = 4
133
+ HEADER_BUTTON_SIZE = (36, 32)
134
+ HEADER_MARGIN = 4
135
+ SHADOW_MARGIN = 24
136
+ FLYOUT_OFFSET_X = 50
137
+ #: 折叠项子菜单 Flyout 的尺寸:整宽 220,左右各 4px Padding,内容宽 212。
138
+ FLYOUT_WIDTH = 220
139
+ FLYOUT_RADIUS = 8
140
+ FLYOUT_ITEM_RADIUS = 4
141
+ FLYOUT_ROW_HEIGHT = 40
142
+ FLYOUT_ITEM_GAP = 4
143
+ FLYOUT_PADDING_X = 4
144
+ FLYOUT_PADDING_Y = 6
145
+ FLYOUT_CONTENT_MARGIN = 12
146
+ HISTORY_LIMIT = 16
147
+ SETTINGS_GLYPH = "\uE713"
148
+ SETTINGS_DIVIDER_COLOR = "#313131"
149
+ SETTINGS_DIVIDER_HEIGHT = 1
150
+ SETTINGS_BOTTOM_MARGIN = 6
151
+ GEAR_PRESS_MS = 150
152
+ GEAR_SPIN_MS = 650
153
+
154
+ #: 侧边栏常态玻璃。
155
+ SIDEBAR_GLASS = BUBBLE_SIDEBAR_NORMAL.copy()
156
+
157
+ #: Flyout 玻璃:取自 glassdemo.py 的参数,圆角改为 4px。
158
+ FLYOUT_GLASS = FrostStyle(
159
+ tint="#2e2e2e",
160
+ opacity=58,
161
+ blur=40,
162
+ frost=18,
163
+ noise=6,
164
+ sheen=14,
165
+ saturation=95,
166
+ brightness=105,
167
+ border=24,
168
+ radius=4,
169
+ vignette=6,
170
+ )
171
+
172
+ #: 折叠项子菜单 Flyout 专用玻璃。
173
+ FLYOUT_FOLD_GLASS = FrostStyle(
174
+ tint="#202020",
175
+ opacity=65,
176
+ blur=30,
177
+ frost=4,
178
+ noise=2,
179
+ sheen=3,
180
+ saturation=95,
181
+ brightness=105,
182
+ border=24,
183
+ radius=4,
184
+ vignette=6,
185
+ )
186
+
187
+ #: 主题默认值:QSS 可通过 ``qproperty-*`` 覆盖,未覆盖时使用这里的配置。
188
+ THEME_DEFAULTS = {
189
+ "selectedBg": QColor(SELECTED_BG),
190
+ "selectedHoverBg": QColor(SELECTED_HOVER_BG),
191
+ "selectedPressedBg": QColor(SELECTED_PRESSED_BG),
192
+ "hoverBg": QColor(HOVER_BG),
193
+ "pressedBg": QColor(PRESSED_BG),
194
+ "textColor": QColor(TEXT_COLOR),
195
+ "pressedText": QColor(PRESSED_TEXT),
196
+ "separatorColor": QColor(SEPARATOR_COLOR),
197
+ "dividerColor": QColor(SETTINGS_DIVIDER_COLOR),
198
+ "indicatorColor": QColor(INDICATOR_COLOR),
199
+ "indicatorWidth": INDICATOR_W,
200
+ "indicatorHeight": INDICATOR_H,
201
+ "navItemRadius": CORNER_RADIUS,
202
+ "flyoutRadius": FLYOUT_RADIUS,
203
+ "flyoutItemRadius": FLYOUT_ITEM_RADIUS,
204
+ }
205
+
206
+ _UNSET = object()
207
+ _QWIDGETSIZE_MAX = 16777215
208
+
209
+
210
+ def _theme_qcolor(name: str):
211
+ def getter(self):
212
+ return self._theme[name]
213
+
214
+ def setter(self, value):
215
+ color = QColor(value)
216
+ if color != self._theme[name]:
217
+ self._theme[name] = color
218
+ self._refresh_theme()
219
+
220
+ return Property(QColor, getter, setter)
221
+
222
+
223
+ def _theme_int(name: str):
224
+ def getter(self):
225
+ return self._theme[name]
226
+
227
+ def setter(self, value):
228
+ number = int(value)
229
+ if number != self._theme[name]:
230
+ self._theme[name] = number
231
+ self._refresh_theme()
232
+
233
+ return Property(int, getter, setter)
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # 枚举
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ class PaneStateEnum(Enum):
242
+ """侧边栏形态。"""
243
+
244
+ Auto = auto() # 使用窗口宽度阈值
245
+ Expanded = auto() # 强制展开
246
+ Compact = auto() # 仅图标
247
+ ForceCompact = auto() # 强制仅图标
248
+ Hide = auto() # 强制隐藏
249
+
250
+
251
+ class PaneShowInlineEnum(Enum):
252
+ """展开呈现方式。"""
253
+
254
+ Auto = auto()
255
+ Inline = auto()
256
+ Flyout = auto()
257
+
258
+
259
+ def _coerce_pane_state(value) -> PaneStateEnum:
260
+ if isinstance(value, PaneStateEnum):
261
+ return value
262
+ if isinstance(value, str):
263
+ key = value.strip().lower().replace("_", "").replace("-", "")
264
+ for member in PaneStateEnum:
265
+ if member.name.lower() == key:
266
+ return member
267
+ raise TypeError(f"无效的 PaneStateEnum: {value!r}")
268
+
269
+
270
+ def _coerce_show_inline(value) -> PaneShowInlineEnum:
271
+ if isinstance(value, PaneShowInlineEnum):
272
+ return value
273
+ if isinstance(value, str):
274
+ key = value.strip().lower().replace("_", "").replace("-", "")
275
+ for member in PaneShowInlineEnum:
276
+ if member.name.lower() == key:
277
+ return member
278
+ raise TypeError(f"无效的 PaneShowInlineEnum: {value!r}")
279
+
280
+
281
+ def _disconnect_signals(*signals) -> None:
282
+ """静默断开(可能没有任何连接的)信号。"""
283
+ with warnings.catch_warnings():
284
+ warnings.simplefilter("ignore", RuntimeWarning)
285
+ for signal in signals:
286
+ try:
287
+ signal.disconnect()
288
+ except (RuntimeError, TypeError):
289
+ pass
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # 缓动与按钮动画(汉堡 / 返回)
294
+ # ---------------------------------------------------------------------------
295
+
296
+
297
+ def cubic_bezier(x1: float, y1: float, x2: float, y2: float):
298
+ """返回 CSS cubic-bezier 缓动函数 f(t), t∈[0,1]。"""
299
+
300
+ def f(t: float) -> float:
301
+ if t <= 0.0:
302
+ return 0.0
303
+ if t >= 1.0:
304
+ return 1.0
305
+ lo, hi = 0.0, 1.0
306
+ for _ in range(22):
307
+ u = (lo + hi) * 0.5
308
+ x = 3.0 * (1.0 - u) ** 2 * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u ** 3
309
+ if x < t:
310
+ lo = u
311
+ else:
312
+ hi = u
313
+ u = (lo + hi) * 0.5
314
+ return 3.0 * (1.0 - u) ** 2 * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u ** 3
315
+
316
+ return f
317
+
318
+
319
+ LINEAR = cubic_bezier(0.0, 0.0, 1.0, 1.0)
320
+ EASE_PRESS = cubic_bezier(0.167, 0.167, 0.0, 1.0)
321
+ EASE_HAMMER_OUT = cubic_bezier(0.85, 0.0, 0.75, 1.0)
322
+ EASE_HAMMER_SETTLE = cubic_bezier(0.35, 0.0, 0.0, 1.0)
323
+ EASE_BACK_TIP = cubic_bezier(0.85, 0.0, 0.75, 1.0)
324
+ EASE_BACK_STEM = cubic_bezier(0.55, 0.0, 0.75, 1.0)
325
+ EASE_BACK_SETTLE = cubic_bezier(0.35, 0.0, 0.0, 1.0)
326
+
327
+ HAMBURGER_W = 44.75
328
+ HAMBURGER_T = 2.75
329
+ HAMBURGER_YS = (-13.75, 0.41, 13.75)
330
+
331
+ BACK_TIP_X = -18.0
332
+ BACK_NOTCH_X = -12.0
333
+ BACK_STEM_RIGHT = 40.875
334
+ BACK_STEM_T = 2.25
335
+
336
+ ICON_COLOR = QColor("#ffffff")
337
+ ICON_COLOR_PRESSED = QColor(PRESSED_TEXT)
338
+ ICON_COLOR_DISABLED = QColor(255, 255, 255, 90)
339
+
340
+
341
+ class AnimatedIconButton(QAbstractButton):
342
+ """汉堡 / 返回按钮:Fluent 风格按下、松开时间线动画。"""
343
+
344
+ def __init__(self, kind: str, icon_size: int = 18,
345
+ button_size=HEADER_BUTTON_SIZE, parent=None):
346
+ super().__init__(parent)
347
+ assert kind in ("hamburger", "back")
348
+ self._kind = kind
349
+ self._icon_size = icon_size
350
+ self.setFixedSize(*button_size)
351
+ self.setFocusPolicy(Qt.StrongFocus)
352
+ self.setCursor(Qt.PointingHandCursor)
353
+ self.setAttribute(Qt.WA_Hover, True)
354
+ self.setAccessibleName({"hamburger": "菜单", "back": "返回"}[kind])
355
+
356
+ self._hover = False
357
+ self._pressed = False
358
+ self._state = "normal"
359
+ self._vals = self._default_vals()
360
+ self._anim: dict | None = None
361
+
362
+ self._timer = QTimer(self)
363
+ self._timer.setTimerType(Qt.PreciseTimer)
364
+ self._timer.setInterval(15)
365
+ self._timer.timeout.connect(self._tick)
366
+
367
+ # -- 状态参数 ---------------------------------------------------------
368
+ def _default_vals(self) -> dict:
369
+ if self._kind == "hamburger":
370
+ return {"wf": 1.0, "ty": 1.0}
371
+ return {"tip_dx": 0.0, "stem_right": BACK_STEM_RIGHT}
372
+
373
+ # -- 状态机 -----------------------------------------------------------
374
+ def _refresh_state(self) -> None:
375
+ if not self.isEnabled():
376
+ new_state = "normal"
377
+ else:
378
+ new_state = "pressed" if self._pressed else ("pointerover" if self._hover else "normal")
379
+ if new_state == self._state:
380
+ return
381
+ old_state, self._state = self._state, new_state
382
+ if new_state == "pressed":
383
+ self._play_press()
384
+ elif old_state == "pressed":
385
+ self._play_release()
386
+ self.update()
387
+
388
+ def _play_press(self) -> None:
389
+ if self._kind == "hamburger":
390
+ self._start([(150.0, {"wf": EASE_PRESS, "ty": EASE_PRESS},
391
+ {"wf": 0.521, "ty": 1.0})])
392
+ else:
393
+ self._start([(150.0, {"tip_dx": EASE_PRESS, "stem_right": EASE_PRESS},
394
+ {"tip_dx": 9.0, "stem_right": 41.813})])
395
+
396
+ def _play_release(self) -> None:
397
+ if self._kind == "hamburger":
398
+ self._start([
399
+ (133.0, {"wf": EASE_HAMMER_OUT, "ty": EASE_HAMMER_OUT},
400
+ {"wf": 1.048, "ty": 0.9937}),
401
+ (184.0, {"wf": EASE_HAMMER_SETTLE, "ty": EASE_HAMMER_SETTLE},
402
+ {"wf": 1.0, "ty": 1.0}),
403
+ ])
404
+ else:
405
+ self._start([
406
+ (167.0, {"tip_dx": EASE_BACK_TIP, "stem_right": EASE_BACK_STEM},
407
+ {"tip_dx": -4.016, "stem_right": 37.125}),
408
+ (150.0, {"tip_dx": EASE_BACK_SETTLE, "stem_right": EASE_BACK_SETTLE},
409
+ {"tip_dx": 0.0, "stem_right": BACK_STEM_RIGHT}),
410
+ ])
411
+
412
+ # -- 时间线 -----------------------------------------------------------
413
+ def _start(self, phases) -> None:
414
+ self._anim = {
415
+ "phases": phases,
416
+ "i": 0,
417
+ "t0": time.perf_counter(),
418
+ "phase_from": dict(self._vals),
419
+ }
420
+ self._timer.start()
421
+
422
+ def _finish(self) -> None:
423
+ self._anim = None
424
+ self._timer.stop()
425
+ self.update()
426
+
427
+ def _tick(self) -> None:
428
+ anim = self._anim
429
+ if anim is None:
430
+ return
431
+ now = time.perf_counter()
432
+ elapsed = (now - anim["t0"]) * 1000.0
433
+ while anim["i"] < len(anim["phases"]):
434
+ duration, eases, targets = anim["phases"][anim["i"]]
435
+ if elapsed < duration:
436
+ p = elapsed / duration
437
+ for key, target in targets.items():
438
+ start = anim["phase_from"][key]
439
+ self._vals[key] = start + (target - start) * eases[key](p)
440
+ self.update()
441
+ return
442
+ self._vals.update(targets)
443
+ elapsed -= duration
444
+ anim["t0"] += duration / 1000.0
445
+ anim["i"] += 1
446
+ if anim["i"] < len(anim["phases"]):
447
+ anim["phase_from"] = dict(self._vals)
448
+ self._finish()
449
+
450
+ # -- 鼠标 / 键盘 ------------------------------------------------------
451
+ def mousePressEvent(self, event) -> None:
452
+ if not self.isEnabled():
453
+ return
454
+ if event.button() == Qt.LeftButton:
455
+ self._pressed = True
456
+ self._refresh_state()
457
+ event.accept()
458
+
459
+ def mouseReleaseEvent(self, event) -> None:
460
+ if not self.isEnabled() or event.button() != Qt.LeftButton or not self._pressed:
461
+ return
462
+ self._pressed = False
463
+ self._hover = self.rect().contains(event.position().toPoint())
464
+ self._refresh_state()
465
+ if self._hover:
466
+ self.click()
467
+ event.accept()
468
+
469
+ def enterEvent(self, event) -> None:
470
+ self._hover = True
471
+ if not self._pressed:
472
+ self._refresh_state()
473
+ super().enterEvent(event)
474
+
475
+ def leaveEvent(self, event) -> None:
476
+ self._hover = False
477
+ if not self._pressed:
478
+ self._refresh_state()
479
+ super().leaveEvent(event)
480
+
481
+ def keyPressEvent(self, event) -> None:
482
+ if not self.isEnabled():
483
+ return
484
+ if event.key() == Qt.Key_Space and not event.isAutoRepeat():
485
+ self._pressed = True
486
+ self._refresh_state()
487
+ event.accept()
488
+ return
489
+ super().keyPressEvent(event)
490
+
491
+ def keyReleaseEvent(self, event) -> None:
492
+ if not self.isEnabled():
493
+ return
494
+ if event.key() == Qt.Key_Space and not event.isAutoRepeat() and self._pressed:
495
+ self._pressed = False
496
+ self._refresh_state()
497
+ self.click()
498
+ event.accept()
499
+ return
500
+ super().keyReleaseEvent(event)
501
+
502
+ def changeEvent(self, event) -> None:
503
+ if event.type() == QEvent.EnabledChange:
504
+ self._pressed = False
505
+ self._hover = self.rect().contains(self.mapFromGlobal(self.cursor().pos()))
506
+ self._refresh_state()
507
+ self.update()
508
+ super().changeEvent(event)
509
+
510
+ # -- 绘制 -------------------------------------------------------------
511
+ def paintEvent(self, event) -> None:
512
+ painter = QPainter(self)
513
+ painter.setRenderHint(QPainter.Antialiasing, True)
514
+ painter.setRenderHint(QPainter.TextAntialiasing, True)
515
+
516
+ enabled = self.isEnabled()
517
+ if enabled and self._pressed:
518
+ painter.fillPath(self._background_path(), QColor(PRESSED_BG))
519
+ elif enabled and self._hover:
520
+ painter.fillPath(self._background_path(), QColor(HOVER_BG))
521
+
522
+ painter.translate(self.width() / 2.0, self.height() / 2.0)
523
+ scale = self._icon_size / 48.0
524
+ painter.scale(scale, scale)
525
+
526
+ if not enabled:
527
+ color = ICON_COLOR_DISABLED
528
+ else:
529
+ color = ICON_COLOR_PRESSED if self._pressed else ICON_COLOR
530
+ if self._kind == "hamburger":
531
+ self._paint_hamburger(painter, color)
532
+ else:
533
+ self._paint_back(painter, color)
534
+ painter.end()
535
+
536
+ def _background_path(self) -> QPainterPath:
537
+ path = QPainterPath()
538
+ path.addRoundedRect(QRectF(self.rect()), CORNER_RADIUS, CORNER_RADIUS)
539
+ return path
540
+
541
+ def _paint_hamburger(self, painter: QPainter, color: QColor) -> None:
542
+ width = HAMBURGER_W * self._vals["wf"]
543
+ thickness = HAMBURGER_T * self._vals["ty"]
544
+ painter.setPen(Qt.NoPen)
545
+ painter.setBrush(color)
546
+ radius = thickness / 2.0
547
+ for y in HAMBURGER_YS:
548
+ rect = QRectF(-width / 2.0, y - thickness / 2.0, width, thickness)
549
+ painter.drawRoundedRect(rect, radius, radius)
550
+
551
+ def _paint_back(self, painter: QPainter, color: QColor) -> None:
552
+ dx = self._vals["tip_dx"]
553
+ stem_right = self._vals["stem_right"] - 24.0
554
+ stem_left = BACK_NOTCH_X + dx
555
+
556
+ head_points = [
557
+ (-1.88, 15.45),
558
+ (-0.37, 13.79),
559
+ (-14.24, 1.12),
560
+ (-11.99, 1.10),
561
+ (-11.99, -1.15),
562
+ (-14.24, -1.13),
563
+ (-0.37, -13.79),
564
+ (-1.88, -15.45),
565
+ (-17.44, -1.25),
566
+ (BACK_TIP_X, 0.0),
567
+ (-17.44, 1.25),
568
+ ]
569
+ head = QPainterPath(QPointF(head_points[0][0] + dx, head_points[0][1]))
570
+ for x, y in head_points[1:]:
571
+ head.lineTo(QPointF(x + dx, y))
572
+ head.closeSubpath()
573
+
574
+ painter.setPen(Qt.NoPen)
575
+ painter.setBrush(color)
576
+ painter.drawPath(head)
577
+
578
+ pen = QPen(color, BACK_STEM_T)
579
+ pen.setCapStyle(Qt.FlatCap)
580
+ painter.setPen(pen)
581
+ painter.drawLine(QPointF(stem_left, 0.0), QPointF(stem_right, 0.0))
582
+
583
+ painter.setPen(Qt.NoPen)
584
+ radius = BACK_STEM_T / 2.0
585
+ painter.drawEllipse(QPointF(stem_right, 0.0), radius, radius)
586
+
587
+
588
+ # ---------------------------------------------------------------------------
589
+ # 胶囊滚动条
590
+ # ---------------------------------------------------------------------------
591
+
592
+
593
+ class _CapsuleScrollBar(QScrollBar):
594
+ """2px 胶囊滚动条;鼠标悬浮时 250ms 动画到 5px。"""
595
+
596
+ def __init__(self, parent=None):
597
+ super().__init__(Qt.Vertical, parent)
598
+ self.setFixedWidth(SCROLLBAR_HIT)
599
+ self.setAutoFillBackground(False)
600
+ self.setFocusPolicy(Qt.NoFocus)
601
+ self.setCursor(Qt.ArrowCursor)
602
+ self._bar_width = float(SCROLLBAR_MIN)
603
+ self._bar_color = QColor(SCROLLBAR_COLOR)
604
+ self._anim = QPropertyAnimation(self, b"barWidth", self)
605
+ self._anim.setDuration(SCROLLBAR_MS)
606
+ self._anim.setEasingCurve(QEasingCurve.OutCubic)
607
+
608
+ def get_bar_color(self) -> QColor:
609
+ return self._bar_color
610
+
611
+ def set_bar_color(self, color):
612
+ color = QColor(color)
613
+ if color != self._bar_color:
614
+ self._bar_color = color
615
+ self.update()
616
+
617
+ barColor = Property(QColor, get_bar_color, set_bar_color)
618
+
619
+ def get_bar_width(self) -> float:
620
+ return self._bar_width
621
+
622
+ def set_bar_width(self, value):
623
+ value = float(value)
624
+ if value != self._bar_width:
625
+ self._bar_width = value
626
+ self.update()
627
+
628
+ barWidth = Property(float, get_bar_width, set_bar_width)
629
+
630
+ def _animate_to(self, target: float) -> None:
631
+ self._anim.stop()
632
+ self._anim.setStartValue(self._bar_width)
633
+ self._anim.setEndValue(float(target))
634
+ self._anim.start()
635
+
636
+ def enterEvent(self, event) -> None:
637
+ self._animate_to(SCROLLBAR_MAX)
638
+ super().enterEvent(event)
639
+
640
+ def leaveEvent(self, event) -> None:
641
+ self._animate_to(SCROLLBAR_MIN)
642
+ super().leaveEvent(event)
643
+
644
+ def paintEvent(self, event) -> None:
645
+ painter = QPainter(self)
646
+ painter.setRenderHint(QPainter.Antialiasing, True)
647
+ painter.setPen(Qt.NoPen)
648
+
649
+ span = self.maximum() - self.minimum()
650
+ if span <= 0:
651
+ return
652
+ bar_width = max(1.0, self._bar_width)
653
+ track = float(self.height())
654
+ page = max(1, self.pageStep())
655
+ handle = max(24.0, track * page / float(page + span))
656
+ travel = max(0.0, track - handle)
657
+ ratio = (self.value() - self.minimum()) / float(span)
658
+ top = travel * ratio
659
+ x = self.width() - bar_width
660
+ painter.setBrush(self._bar_color)
661
+ painter.drawRoundedRect(
662
+ QRectF(x, top, bar_width, handle), bar_width / 2.0, bar_width / 2.0
663
+ )
664
+ painter.end()
665
+
666
+
667
+ # ---------------------------------------------------------------------------
668
+ # 数据模型
669
+ # ---------------------------------------------------------------------------
670
+
671
+
672
+ class NavItem(QObject):
673
+ """普通菜单项(也用于折叠菜单的子项与内置设置项)。
674
+
675
+ 顶层项的 ``globalIndex`` 是导航选择索引;子项的 ``index`` 是兄弟局部索引,
676
+ ``globalIndex`` 返回父折叠项的全局索引(选择 API 只按顶层/折叠项编号)。
677
+ """
678
+
679
+ clicked = Signal()
680
+ pressed = Signal()
681
+ released = Signal()
682
+ stateChanged = Signal(bool)
683
+
684
+ def __init__(self, navigation, fonticon: str, name: str, layout=None,
685
+ fold=None, settings: bool = False):
686
+ super().__init__(navigation)
687
+ self._nav = navigation
688
+ self._fonticon = fonticon or ""
689
+ self._name = name or ""
690
+ self._layout = layout
691
+ self._fold = fold
692
+ self._settings = bool(settings)
693
+ self._selected = False
694
+ self._alive = True
695
+ self._index = -1
696
+ self._row: "_NavRow | None" = None
697
+ self._wrapper: "QWidget | None" = None
698
+ self._page: "_ContentPage | None" = None
699
+
700
+ # -- 有效性 -----------------------------------------------------------
701
+ def isValid(self) -> bool:
702
+ return self._alive
703
+
704
+ @property
705
+ def deleted(self) -> bool:
706
+ return not self._alive
707
+
708
+ def _require_alive(self) -> None:
709
+ if not self._alive:
710
+ raise RuntimeError("该 NavItem 已被移除")
711
+
712
+ # -- 属性 -------------------------------------------------------------
713
+ @property
714
+ def fonticon(self) -> str:
715
+ return self._fonticon if self._alive else ""
716
+
717
+ @fonticon.setter
718
+ def fonticon(self, value: str):
719
+ self._require_alive()
720
+ self._fonticon = value or ""
721
+ self._nav._refresh_item(self)
722
+
723
+ @property
724
+ def name(self) -> str:
725
+ return self._name if self._alive else ""
726
+
727
+ @name.setter
728
+ def name(self, value: str):
729
+ self._require_alive()
730
+ self._name = value or ""
731
+ self._nav._refresh_item(self)
732
+
733
+ @property
734
+ def layout(self):
735
+ return self._layout if self._alive else None
736
+
737
+ @layout.setter
738
+ def layout(self, value):
739
+ self._require_alive()
740
+ self._nav._set_item_layout(self, value)
741
+
742
+ @property
743
+ def index(self) -> int:
744
+ return self._index if self._alive else -1
745
+
746
+ @property
747
+ def globalIndex(self) -> int:
748
+ if not self._alive:
749
+ return -1
750
+ if self._fold is not None:
751
+ return self._fold.globalIndex
752
+ return self._nav._global_index_of(self)
753
+
754
+ @property
755
+ def selected(self) -> bool:
756
+ return self._selected if self._alive else False
757
+
758
+ @property
759
+ def isSettings(self) -> bool:
760
+ return self._settings
761
+
762
+ def select(self, animate: bool = False) -> None:
763
+ """程序化选中本项(子项也可直接调用)。"""
764
+ self._require_alive()
765
+ if self._settings:
766
+ self._nav._select_target(top=None, child=None, settings=True,
767
+ animate=animate, record=False)
768
+ return
769
+ if self._fold is not None:
770
+ self._nav._select_target(top=self._fold, child=self, settings=False,
771
+ animate=animate, record=False)
772
+ else:
773
+ self._nav._select_target(top=self, child=None, settings=False,
774
+ animate=animate, record=False)
775
+
776
+ def _set_selected(self, value: bool) -> None:
777
+ if not self._alive:
778
+ return
779
+ value = bool(value)
780
+ if value == self._selected:
781
+ return
782
+ self._selected = value
783
+ if self._row is not None:
784
+ self._row.set_selected(value)
785
+ self.stateChanged.emit(value)
786
+
787
+ def _invalidate(self) -> None:
788
+ if not self._alive:
789
+ return
790
+ self._alive = False
791
+ self._selected = False
792
+ self._layout = None
793
+ self._index = -1
794
+ if self._row is not None:
795
+ self._row.deleteLater()
796
+ self._row = None
797
+ if self._wrapper is not None:
798
+ self._wrapper.deleteLater()
799
+ self._wrapper = None
800
+ _disconnect_signals(self.clicked, self.pressed, self.released,
801
+ self.stateChanged)
802
+ self.setParent(None)
803
+
804
+ def __repr__(self) -> str:
805
+ state = "deleted" if not self._alive else f"index={self._index}"
806
+ return f"NavItem({self._name!r}, {state})"
807
+
808
+
809
+ class NavFoldItem(QObject):
810
+ """可展开菜单项。内部只能添加普通子项,不能再嵌套折叠项。
811
+
812
+ ``index`` 是折叠项内部的局部索引,``globalIndex`` 是顶层选择索引。
813
+ ``selected`` 表示标题或任一子项被选中(分支选中)。
814
+ """
815
+
816
+ clicked = Signal()
817
+ pressed = Signal()
818
+ released = Signal()
819
+ selectedChanged = Signal(int)
820
+ foldStateChanged = Signal(bool)
821
+ stateChanged = Signal(bool)
822
+
823
+ def __init__(self, navigation, fonticon: str, name: str, layout=None):
824
+ super().__init__(navigation)
825
+ self._nav = navigation
826
+ self._fonticon = fonticon or ""
827
+ self._name = name or ""
828
+ self._layout = layout
829
+ self._selected = False
830
+ self._expanded = False
831
+ self._alive = True
832
+ self._index = -1
833
+ self._children: list[NavItem] = []
834
+ self._last_child_selected: int | None = None
835
+ self._row: "_FoldRow | None" = None
836
+ self._group: "_FoldGroup | None" = None
837
+ self._page: "_ContentPage | None" = None
838
+
839
+ # -- 有效性 -----------------------------------------------------------
840
+ def isValid(self) -> bool:
841
+ return self._alive
842
+
843
+ @property
844
+ def deleted(self) -> bool:
845
+ return not self._alive
846
+
847
+ def _require_alive(self) -> None:
848
+ if not self._alive:
849
+ raise RuntimeError("该 NavFoldItem 已被移除")
850
+
851
+ # -- 属性 -------------------------------------------------------------
852
+ @property
853
+ def fonticon(self) -> str:
854
+ return self._fonticon if self._alive else ""
855
+
856
+ @fonticon.setter
857
+ def fonticon(self, value: str):
858
+ self._require_alive()
859
+ self._fonticon = value or ""
860
+ self._nav._refresh_item(self)
861
+
862
+ @property
863
+ def name(self) -> str:
864
+ return self._name if self._alive else ""
865
+
866
+ @name.setter
867
+ def name(self, value: str):
868
+ self._require_alive()
869
+ self._name = value or ""
870
+ self._nav._refresh_item(self)
871
+
872
+ @property
873
+ def layout(self):
874
+ return self._layout if self._alive else None
875
+
876
+ @layout.setter
877
+ def layout(self, value):
878
+ self._require_alive()
879
+ self._nav._set_item_layout(self, value)
880
+
881
+ @property
882
+ def index(self) -> int:
883
+ return self._index if self._alive else -1
884
+
885
+ @property
886
+ def globalIndex(self) -> int:
887
+ if not self._alive:
888
+ return -1
889
+ return self._nav._global_index_of(self)
890
+
891
+ @property
892
+ def selected(self) -> bool:
893
+ return self._selected if self._alive else False
894
+
895
+ @property
896
+ def expanded(self) -> bool:
897
+ return self._expanded if self._alive else False
898
+
899
+ @expanded.setter
900
+ def expanded(self, value: bool):
901
+ self._require_alive()
902
+ self._nav._set_fold_expanded(self, bool(value), animate=True)
903
+
904
+ def children(self) -> list[NavItem]:
905
+ return [child for child in self._children if child.isValid()]
906
+
907
+ def select(self, animate: bool = False) -> None:
908
+ self._require_alive()
909
+ self._nav._select_target(top=self, child=None, settings=False,
910
+ animate=animate, record=False)
911
+
912
+ # -- 子项 -------------------------------------------------------------
913
+ def addNavItem(self, fonticon: str, name: str, layout=None) -> NavItem:
914
+ self._require_alive()
915
+ return self._nav._add_child(self, fonticon, name, layout)
916
+
917
+ def _set_selected(self, value: bool) -> None:
918
+ if not self._alive:
919
+ return
920
+ value = bool(value)
921
+ if value == self._selected:
922
+ return
923
+ self._selected = value
924
+ self.stateChanged.emit(value)
925
+
926
+ def _set_expanded(self, value: bool) -> None:
927
+ if not self._alive:
928
+ return
929
+ value = bool(value)
930
+ if value == self._expanded:
931
+ return
932
+ self._expanded = value
933
+ self.foldStateChanged.emit(value)
934
+
935
+ def _emit_child_selected(self, index: int) -> None:
936
+ if not self._alive:
937
+ return
938
+ if index == self._last_child_selected:
939
+ return
940
+ self._last_child_selected = index
941
+ self.selectedChanged.emit(index)
942
+
943
+ def _invalidate(self) -> None:
944
+ if not self._alive:
945
+ return
946
+ self._alive = False
947
+ self._selected = False
948
+ self._expanded = False
949
+ self._nav._remembered_folds.discard(self)
950
+ # 子项的控件由 _FoldGroup 统一销毁,这里只让模型失效。
951
+ for child in self._children:
952
+ child._alive = False
953
+ child._selected = False
954
+ child._layout = None
955
+ child._index = -1
956
+ child._row = None
957
+ child._wrapper = None
958
+ _disconnect_signals(child.clicked, child.pressed, child.released,
959
+ child.stateChanged)
960
+ child.setParent(None)
961
+ self._children = []
962
+ self._layout = None
963
+ self._index = -1
964
+ if self._group is not None:
965
+ self._group.deleteLater()
966
+ self._group = None
967
+ self._row = None
968
+ _disconnect_signals(self.clicked, self.pressed, self.released,
969
+ self.selectedChanged, self.foldStateChanged,
970
+ self.stateChanged)
971
+ self.setParent(None)
972
+
973
+ def __repr__(self) -> str:
974
+ state = "deleted" if not self._alive else f"index={self._index}"
975
+ return f"NavFoldItem({self._name!r}, {state})"
976
+
977
+
978
+ class _SeparatorEntry:
979
+ """内部:分隔描述符模型。"""
980
+
981
+ def __init__(self, text: str):
982
+ self.text = text or ""
983
+ self.widget: "_SeparatorWidget | None" = None
984
+
985
+
986
+ class _LineEntry:
987
+ """内部:``addSeparateLine()`` 添加的 1px 横线模型。"""
988
+
989
+ def __init__(self):
990
+ self.widget: "QWidget | None" = None
991
+
992
+
993
+ # ---------------------------------------------------------------------------
994
+ # 菜单行
995
+ # ---------------------------------------------------------------------------
996
+
997
+
998
+ def _wrap_row(row: QWidget) -> QWidget:
999
+ """给一行加上 4px 上边距(左右/下边距由菜单布局提供)。"""
1000
+ wrapper = QWidget()
1001
+ wrapper.setFixedHeight(ROW_HEIGHT + ROW_MARGIN_TOP)
1002
+ layout = QVBoxLayout(wrapper)
1003
+ layout.setContentsMargins(0, ROW_MARGIN_TOP, 0, 0)
1004
+ layout.setSpacing(0)
1005
+ layout.addWidget(row)
1006
+ return wrapper
1007
+
1008
+
1009
+ class _NavRow(QWidget):
1010
+ """自绘菜单行:背景、图标、文字、折叠箭头。"""
1011
+
1012
+ clicked = Signal()
1013
+ pressed = Signal()
1014
+ released = Signal()
1015
+
1016
+ def __init__(self, item, compact: bool = False, child: bool = False,
1017
+ parent=None, content_margin: int | None = None):
1018
+ super().__init__(parent)
1019
+ self._item = item
1020
+ self._compact = bool(compact)
1021
+ self._child = bool(child)
1022
+ self._content_margin = content_margin
1023
+ self._selected = False
1024
+ self._hover = False
1025
+ self._pressed = False
1026
+ self._expanded = False
1027
+ self._fold = isinstance(item, NavFoldItem)
1028
+
1029
+ self.setFixedHeight(ROW_HEIGHT)
1030
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
1031
+ self.setCursor(Qt.PointingHandCursor)
1032
+ self.setAttribute(Qt.WA_Hover, True)
1033
+ self.setAccessibleName(item.name)
1034
+
1035
+ self._font = QFont(APP_FONT)
1036
+ self._font.setPixelSize(TEXT_SIZE)
1037
+
1038
+ self._icon = FontIcon(item.fonticon, size=ICON_SIZE, parent=self)
1039
+ self._icon.setAttribute(Qt.WA_TransparentForMouseEvents, True)
1040
+ self._arrow: FontIcon | None = None
1041
+ if self._fold:
1042
+ self._arrow = fonticon(ARROW_GLYPH, size=ARROW_SIZE)
1043
+ self._arrow.setParent(self)
1044
+ self._arrow.setAttribute(Qt.WA_TransparentForMouseEvents, True)
1045
+ self._arrow.set_angle(0.0)
1046
+
1047
+ self._arrow_anim = QPropertyAnimation(self._arrow, b"angle", self) if self._arrow else None
1048
+ if self._arrow_anim is not None:
1049
+ self._arrow_anim.setDuration(ANIM_MS)
1050
+ self._arrow_anim.setEasingCurve(QEasingCurve.InOutCubic)
1051
+
1052
+ # 内置“设置”项的齿轮:按下 -20°,松开转完整一圈(与旧版按钮动画一致)。
1053
+ self._gear_anim: QPropertyAnimation | None = None
1054
+ if getattr(item, "isSettings", False):
1055
+ self.pressed.connect(self._on_gear_press)
1056
+ self.released.connect(self._on_gear_release)
1057
+
1058
+ self._apply_visual_state()
1059
+ self._layout_children()
1060
+
1061
+ # -- 外部状态 ---------------------------------------------------------
1062
+ def set_selected(self, value: bool) -> None:
1063
+ value = bool(value)
1064
+ if value == self._selected:
1065
+ return
1066
+ self._selected = value
1067
+ self._apply_visual_state()
1068
+ self.update()
1069
+
1070
+ def set_compact(self, compact: bool) -> None:
1071
+ compact = bool(compact)
1072
+ if compact == self._compact:
1073
+ return
1074
+ self._compact = compact
1075
+ self.setToolTip(self._item.name if compact else "")
1076
+ self._layout_children()
1077
+ self.update()
1078
+
1079
+ def set_expanded(self, expanded: bool, animate: bool = True) -> None:
1080
+ if self._arrow is None:
1081
+ return
1082
+ expanded = bool(expanded)
1083
+ if expanded == self._expanded:
1084
+ return
1085
+ self._expanded = expanded
1086
+ target = 180.0 if expanded else 0.0
1087
+ if self._arrow_anim is not None and animate:
1088
+ self._arrow_anim.stop()
1089
+ self._arrow_anim.setStartValue(float(self._arrow.get_angle()))
1090
+ self._arrow_anim.setEndValue(target)
1091
+ self._arrow_anim.start()
1092
+ else:
1093
+ if self._arrow_anim is not None:
1094
+ self._arrow_anim.stop()
1095
+ self._arrow.set_angle(target)
1096
+
1097
+ def refresh_content(self) -> None:
1098
+ self._icon.setText(self._item.fonticon)
1099
+ self.setAccessibleName(self._item.name)
1100
+ self.setToolTip(self._item.name if self._compact else "")
1101
+ self._layout_children()
1102
+ self.update()
1103
+
1104
+ # -- 设置齿轮转圈 -----------------------------------------------------
1105
+ def _stop_gear_anim(self) -> None:
1106
+ if self._gear_anim is not None:
1107
+ self._gear_anim.stop()
1108
+ self._gear_anim = None
1109
+
1110
+ def _on_gear_press(self) -> None:
1111
+ if not self._item.isSettings:
1112
+ return
1113
+ self._stop_gear_anim()
1114
+ start = float(self._icon.get_angle())
1115
+ anim = QPropertyAnimation(self._icon, b"angle", self)
1116
+ anim.setDuration(GEAR_PRESS_MS)
1117
+ anim.setEasingCurve(QEasingCurve.Linear)
1118
+ anim.setStartValue(start)
1119
+ anim.setEndValue(start - 20.0)
1120
+ self._gear_anim = anim
1121
+ anim.start()
1122
+
1123
+ def _on_gear_release(self) -> None:
1124
+ if not self._item.isSettings:
1125
+ return
1126
+ self._stop_gear_anim()
1127
+ start = float(self._icon.get_angle())
1128
+ target = 360.0 if start <= 0.0 else 360.0 * math.ceil(start / 360.0)
1129
+ anim = QPropertyAnimation(self._icon, b"angle", self)
1130
+ anim.setDuration(GEAR_SPIN_MS)
1131
+ # 对应 cubic-bezier(0, 0, 0, 1) 的强 ease-out。
1132
+ anim.setEasingCurve(QEasingCurve.OutCubic)
1133
+ anim.setStartValue(start)
1134
+ anim.setEndValue(target)
1135
+ anim.finished.connect(self._normalize_gear_angle)
1136
+ self._gear_anim = anim
1137
+ anim.start()
1138
+
1139
+ def _normalize_gear_angle(self) -> None:
1140
+ self._gear_anim = None
1141
+ self._icon.set_angle(self._icon.get_angle() % 360.0)
1142
+
1143
+ # -- 主题 -------------------------------------------------------------
1144
+ def _theme(self) -> dict:
1145
+ nav = getattr(self._item, "_nav", None)
1146
+ return getattr(nav, "_theme", THEME_DEFAULTS)
1147
+
1148
+ def _item_radius(self) -> int:
1149
+ key = ("flyoutItemRadius" if self._content_margin is not None
1150
+ else "navItemRadius")
1151
+ return int(self._theme()[key])
1152
+
1153
+ # -- 状态 -------------------------------------------------------------
1154
+ def _background_color(self) -> QColor | None:
1155
+ theme = self._theme()
1156
+ if self._pressed:
1157
+ key = "selectedPressedBg" if self._selected else "pressedBg"
1158
+ return QColor(theme[key])
1159
+ if self._hover:
1160
+ key = "selectedHoverBg" if self._selected else "hoverBg"
1161
+ return QColor(theme[key])
1162
+ if self._selected:
1163
+ return QColor(theme["selectedBg"])
1164
+ return None
1165
+
1166
+ def _text_color(self) -> QColor:
1167
+ theme = self._theme()
1168
+ return QColor(theme["pressedText"] if self._pressed else theme["textColor"])
1169
+
1170
+ def _apply_visual_state(self) -> None:
1171
+ color = self._text_color()
1172
+ self._icon.set_icon_color(color)
1173
+ if self._arrow is not None:
1174
+ self._arrow.set_icon_color(color)
1175
+
1176
+ # -- 尺寸 -------------------------------------------------------------
1177
+ def resizeEvent(self, event) -> None:
1178
+ super().resizeEvent(event)
1179
+ self._layout_children()
1180
+
1181
+ def _layout_children(self) -> None:
1182
+ width = self.width()
1183
+ center_y = self.height() // 2
1184
+ has_icon = bool(self._item.fonticon)
1185
+ margin = (ROW_ICON_INDENT if self._content_margin is None
1186
+ else self._content_margin)
1187
+ indent = margin + (CHILD_INDENT if self._child else 0)
1188
+ if self._compact:
1189
+ if has_icon:
1190
+ icon_x = max(0, (width - ICON_SIZE) // 2)
1191
+ self._icon.setGeometry(icon_x, center_y - ICON_SIZE // 2,
1192
+ ICON_SIZE, ICON_SIZE)
1193
+ self._icon.show()
1194
+ else:
1195
+ self._icon.hide()
1196
+ self._arrow and self._arrow.hide()
1197
+ else:
1198
+ if width <= 0:
1199
+ return
1200
+ if has_icon:
1201
+ self._icon.setGeometry(indent, center_y - ICON_SIZE // 2,
1202
+ ICON_SIZE, ICON_SIZE)
1203
+ self._icon.show()
1204
+ else:
1205
+ self._icon.hide()
1206
+ if self._arrow is not None:
1207
+ arrow_x = max(0, width - 16 - ARROW_SIZE)
1208
+ self._arrow.setGeometry(arrow_x, center_y - ARROW_SIZE // 2,
1209
+ ARROW_SIZE, ARROW_SIZE)
1210
+ self._arrow.show()
1211
+ self._apply_visual_state()
1212
+ self.update()
1213
+
1214
+ def _text_rect(self) -> QRectF:
1215
+ margin = (ROW_ICON_INDENT if self._content_margin is None
1216
+ else self._content_margin)
1217
+ left = margin + (CHILD_INDENT if self._child else 0)
1218
+ if self._item.fonticon:
1219
+ left += ICON_SIZE + ICON_GAP
1220
+ right = self.width() - (16 if self._content_margin is None else margin)
1221
+ if self._fold:
1222
+ right -= ARROW_SIZE + 8
1223
+ return QRectF(left, 0, max(0, right - left), self.height())
1224
+
1225
+ # -- 绘制 -------------------------------------------------------------
1226
+ def paintEvent(self, event) -> None:
1227
+ painter = QPainter(self)
1228
+ painter.setRenderHint(QPainter.Antialiasing, True)
1229
+ painter.setRenderHint(QPainter.TextAntialiasing, True)
1230
+
1231
+ bg = self._background_color()
1232
+ if bg is not None:
1233
+ radius = self._item_radius()
1234
+ painter.setPen(Qt.NoPen)
1235
+ painter.setBrush(bg)
1236
+ painter.drawRoundedRect(QRectF(self.rect()), radius, radius)
1237
+
1238
+ if not self._compact:
1239
+ painter.setPen(self._text_color())
1240
+ painter.setFont(self._font)
1241
+ text_rect = self._text_rect()
1242
+ metrics = QFontMetrics(self._font)
1243
+ text = metrics.elidedText(self._item.name, Qt.ElideRight,
1244
+ max(1, int(text_rect.width())))
1245
+ painter.drawText(text_rect, Qt.AlignVCenter | Qt.AlignLeft, text)
1246
+ painter.end()
1247
+
1248
+ # -- 交互 -------------------------------------------------------------
1249
+ def enterEvent(self, event) -> None:
1250
+ self._hover = True
1251
+ self.update()
1252
+ super().enterEvent(event)
1253
+
1254
+ def leaveEvent(self, event) -> None:
1255
+ self._hover = False
1256
+ if not self._pressed:
1257
+ self.update()
1258
+ super().leaveEvent(event)
1259
+
1260
+ def mousePressEvent(self, event) -> None:
1261
+ if event.button() == Qt.LeftButton:
1262
+ self._pressed = True
1263
+ self._apply_visual_state()
1264
+ self.update()
1265
+ self.pressed.emit()
1266
+ event.accept()
1267
+
1268
+ def mouseReleaseEvent(self, event) -> None:
1269
+ if event.button() != Qt.LeftButton or not self._pressed:
1270
+ return
1271
+ inside = self.rect().contains(event.position().toPoint())
1272
+ self._pressed = False
1273
+ self._apply_visual_state()
1274
+ self.update()
1275
+ self.released.emit()
1276
+ if inside:
1277
+ self.clicked.emit()
1278
+ event.accept()
1279
+
1280
+
1281
+ class _FoldRow(_NavRow):
1282
+ """折叠菜单标题行(与普通行同尺寸,额外显示箭头)。"""
1283
+
1284
+
1285
+ class _FoldBody(QWidget):
1286
+ """折叠项的子项容器:maximumHeight 动画。"""
1287
+
1288
+ heightChanged = Signal()
1289
+
1290
+ def __init__(self, parent=None):
1291
+ super().__init__(parent)
1292
+ self._layout = QVBoxLayout(self)
1293
+ self._layout.setContentsMargins(0, 0, 0, 0)
1294
+ self._layout.setSpacing(0)
1295
+ self._expanded = False
1296
+ self.setMaximumHeight(0)
1297
+ self.setVisible(False)
1298
+ self._anim = QPropertyAnimation(self, b"maximumHeight", self)
1299
+ self._anim.setDuration(ANIM_MS)
1300
+ self._anim.setEasingCurve(QEasingCurve.InOutCubic)
1301
+ self._anim.valueChanged.connect(lambda _value: self.heightChanged.emit())
1302
+ self._anim.finished.connect(self._on_anim_finished)
1303
+
1304
+ def add_wrapper(self, wrapper: QWidget) -> None:
1305
+ self._layout.addWidget(wrapper)
1306
+ if self._expanded:
1307
+ self.setMaximumHeight(self._natural_height())
1308
+ self.heightChanged.emit()
1309
+
1310
+ def remove_wrapper(self, wrapper: QWidget) -> None:
1311
+ self._layout.removeWidget(wrapper)
1312
+
1313
+ def _natural_height(self) -> int:
1314
+ return max(0, self._layout.sizeHint().height())
1315
+
1316
+ def set_expanded(self, expanded: bool, animate: bool = True) -> None:
1317
+ expanded = bool(expanded)
1318
+ if expanded == self._expanded:
1319
+ if expanded:
1320
+ self.setMaximumHeight(self._natural_height())
1321
+ return
1322
+ self._expanded = expanded
1323
+ self._anim.stop()
1324
+ if expanded:
1325
+ self.setVisible(True)
1326
+ natural = self._natural_height()
1327
+ if animate:
1328
+ self.setMaximumHeight(0)
1329
+ self._anim.setStartValue(0)
1330
+ self._anim.setEndValue(natural)
1331
+ self._anim.start()
1332
+ else:
1333
+ self.setMaximumHeight(_QWIDGETSIZE_MAX if natural >= 0 else 0)
1334
+ else:
1335
+ if animate and self.height() > 0:
1336
+ self._anim.setStartValue(self.height())
1337
+ self._anim.setEndValue(0)
1338
+ self._anim.start()
1339
+ else:
1340
+ self.setMaximumHeight(0)
1341
+ self.setVisible(False)
1342
+
1343
+ def _on_anim_finished(self) -> None:
1344
+ if self._expanded:
1345
+ self.setMaximumHeight(_QWIDGETSIZE_MAX)
1346
+ else:
1347
+ self.setVisible(False)
1348
+ self.setMaximumHeight(0)
1349
+ self.heightChanged.emit()
1350
+
1351
+
1352
+ class _FoldGroup(QWidget):
1353
+ """折叠标题 + 子项容器的组合控件。"""
1354
+
1355
+ def __init__(self, fold: NavFoldItem, header: _FoldRow, parent=None):
1356
+ super().__init__(parent)
1357
+ self._fold = fold
1358
+ self._header = header
1359
+ self._body = _FoldBody(self)
1360
+ self._header_wrapper = _wrap_row(header)
1361
+ self._layout = QVBoxLayout(self)
1362
+ self._layout.setContentsMargins(0, 0, 0, 0)
1363
+ self._layout.setSpacing(0)
1364
+ self._layout.addWidget(self._header_wrapper)
1365
+ self._layout.addWidget(self._body)
1366
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
1367
+
1368
+ def header_wrapper(self) -> QWidget:
1369
+ return self._header_wrapper
1370
+
1371
+ def body(self) -> _FoldBody:
1372
+ return self._body
1373
+
1374
+ def add_child(self, row: _NavRow) -> None:
1375
+ self._body.add_wrapper(_wrap_row(row))
1376
+
1377
+ def set_expanded(self, expanded: bool, animate: bool = True) -> None:
1378
+ self._header.set_expanded(expanded, animate=animate)
1379
+ self._body.set_expanded(expanded, animate=animate)
1380
+
1381
+
1382
+ class _SeparatorWidget(QWidget):
1383
+ """加粗 #cccccc 的分隔描述符。"""
1384
+
1385
+ heightChanged = Signal()
1386
+
1387
+ def __init__(self, text: str, parent=None, navigation=None):
1388
+ super().__init__(parent)
1389
+ self._nav = navigation
1390
+ self._text = text or ""
1391
+ self._compact = False
1392
+ self._font = QFont(APP_FONT)
1393
+ self._font.setPixelSize(SEPARATOR_SIZE)
1394
+ self._font.setBold(True)
1395
+ metrics = QFontMetrics(self._font)
1396
+ self._natural = SEPARATOR_TOP + metrics.height() + SEPARATOR_BOTTOM
1397
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
1398
+ self.setMaximumHeight(self._natural)
1399
+ self._anim = QPropertyAnimation(self, b"maximumHeight", self)
1400
+ self._anim.setDuration(ANIM_MS)
1401
+ self._anim.setEasingCurve(QEasingCurve.InOutCubic)
1402
+ self._anim.valueChanged.connect(lambda _value: self.heightChanged.emit())
1403
+ self._anim.finished.connect(self._on_anim_finished)
1404
+
1405
+ def set_text(self, text: str) -> None:
1406
+ self._text = text or ""
1407
+ self.update()
1408
+
1409
+ def sizeHint(self) -> QSize:
1410
+ return QSize(0, self._natural)
1411
+
1412
+ def minimumSizeHint(self) -> QSize:
1413
+ return QSize(0, 0)
1414
+
1415
+ def set_compact(self, compact: bool, animate: bool = True) -> None:
1416
+ compact = bool(compact)
1417
+ if compact == self._compact and self.isVisible() == (not compact):
1418
+ return
1419
+ self._compact = compact
1420
+ self._anim.stop()
1421
+ if compact:
1422
+ if animate and self.isVisible():
1423
+ self._anim.setStartValue(self.height())
1424
+ self._anim.setEndValue(0)
1425
+ self._anim.start()
1426
+ else:
1427
+ self.setMaximumHeight(0)
1428
+ self.setVisible(False)
1429
+ else:
1430
+ self.setVisible(True)
1431
+ if animate:
1432
+ self.setMaximumHeight(0)
1433
+ self._anim.setStartValue(0)
1434
+ self._anim.setEndValue(self._natural)
1435
+ self._anim.start()
1436
+ else:
1437
+ self.setMaximumHeight(self._natural)
1438
+
1439
+ def _on_anim_finished(self) -> None:
1440
+ if self._compact:
1441
+ self.setVisible(False)
1442
+ self.setMaximumHeight(0)
1443
+ else:
1444
+ self.setMaximumHeight(self._natural)
1445
+ self.heightChanged.emit()
1446
+
1447
+ def paintEvent(self, event) -> None:
1448
+ painter = QPainter(self)
1449
+ painter.setRenderHint(QPainter.TextAntialiasing, True)
1450
+ theme = getattr(self._nav, "_theme", THEME_DEFAULTS)
1451
+ painter.setPen(QColor(theme["separatorColor"]))
1452
+ painter.setFont(self._font)
1453
+ metrics = QFontMetrics(self._font)
1454
+ rect = QRectF(TOP_INDENT, SEPARATOR_TOP, self.width() - TOP_INDENT * 2,
1455
+ metrics.height())
1456
+ painter.drawText(rect, Qt.AlignLeft | Qt.AlignVCenter,
1457
+ metrics.elidedText(self._text, Qt.ElideRight,
1458
+ max(1, int(rect.width()))))
1459
+ painter.end()
1460
+
1461
+
1462
+ class _SettingsDivider(QWidget):
1463
+ """1px 分割线:设置底栏上方与 ``addSeparateLine()`` 共用。"""
1464
+
1465
+ def __init__(self, navigation=None, parent=None):
1466
+ super().__init__(parent)
1467
+ self._nav = navigation
1468
+ self.setFixedHeight(SETTINGS_DIVIDER_HEIGHT)
1469
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
1470
+ self.setAutoFillBackground(False)
1471
+
1472
+ def paintEvent(self, event) -> None:
1473
+ theme = getattr(self._nav, "_theme", THEME_DEFAULTS)
1474
+ painter = QPainter(self)
1475
+ painter.fillRect(self.rect(), QColor(theme["dividerColor"]))
1476
+ painter.end()
1477
+
1478
+
1479
+ # ---------------------------------------------------------------------------
1480
+ # 指示器
1481
+ # ---------------------------------------------------------------------------
1482
+
1483
+
1484
+ class _IndicatorOverlay(QWidget):
1485
+ """2×16 胶囊选中指示器,贴菜单行左边界,带两段 125ms 位移/拉伸动画。"""
1486
+
1487
+ def __init__(self, pane, parent=None):
1488
+ super().__init__(parent or pane)
1489
+ self._pane = pane
1490
+ self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
1491
+ self.setAutoFillBackground(False)
1492
+ self._row: QWidget | None = None
1493
+ self._anchor_row: QWidget | None = None
1494
+ self._top = 0.0
1495
+ self._bottom = 0.0
1496
+ self._edge = ""
1497
+ self._frozen_top = 0.0
1498
+ self._frozen_bottom = 0.0
1499
+ self._phase = 0
1500
+ self._new_row: QWidget | None = None
1501
+ self._above = True
1502
+ self._default_top = 0.0
1503
+ self._default_bottom = 0.0
1504
+
1505
+ self._anim = QVariantAnimation(self)
1506
+ self._anim.setDuration(INDICATOR_STEP_MS)
1507
+ self._anim.setEasingCurve(QEasingCurve.OutCubic)
1508
+ self._anim.valueChanged.connect(self._on_anim_value)
1509
+ self._anim.finished.connect(self._on_anim_finished)
1510
+
1511
+ self._slide_anim = QVariantAnimation(self)
1512
+ self._slide_anim.setDuration(INDICATOR_STEP_MS)
1513
+ self._slide_anim.setEasingCurve(QEasingCurve.OutCubic)
1514
+ self._slide_anim.valueChanged.connect(self._on_slide_value)
1515
+ self._slide_from_top = 0.0
1516
+ self._slide_from_bottom = 0.0
1517
+ self._slide_to_top = 0.0
1518
+ self._slide_to_bottom = 0.0
1519
+ self.hide()
1520
+
1521
+ # -- 主题 -------------------------------------------------------------
1522
+ def _theme(self) -> dict:
1523
+ nav = getattr(self._pane, "_nav", None)
1524
+ return getattr(nav, "_theme", THEME_DEFAULTS)
1525
+
1526
+ # -- 几何 -------------------------------------------------------------
1527
+ def _row_rect(self, row: QWidget) -> QRect:
1528
+ row_global = row.mapToGlobal(QPoint(0, 0))
1529
+ overlay_global = self.mapToGlobal(QPoint(0, 0))
1530
+ return QRect(row_global.x() - overlay_global.x(),
1531
+ row_global.y() - overlay_global.y(),
1532
+ row.width(), row.height())
1533
+
1534
+ def _default_span(self, row: QWidget) -> tuple[float, float]:
1535
+ rect = self._row_rect(row)
1536
+ center = rect.center().y()
1537
+ height = float(self._theme()["indicatorHeight"])
1538
+ return center - height / 2.0, center + height / 2.0
1539
+
1540
+ def _is_row_visible(self, row: QWidget) -> bool:
1541
+ if row is None or not row.isVisible():
1542
+ return False
1543
+ return self._pane.row_visible_for_indicator(row)
1544
+
1545
+ # -- 对外接口 ---------------------------------------------------------
1546
+ def set_row(self, row: QWidget | None, animate: bool = True) -> None:
1547
+ self._anim.stop()
1548
+ self._anchor_row = row
1549
+ if row is None or not self._is_row_visible(row):
1550
+ self._row = None
1551
+ self.hide()
1552
+ return
1553
+
1554
+ old_row = self._row
1555
+ if old_row is row:
1556
+ self._row = row
1557
+ self._top, self._bottom = self._default_span(row)
1558
+ self.show()
1559
+ self.raise_()
1560
+ self.update()
1561
+ return
1562
+
1563
+ self.show()
1564
+ self.raise_()
1565
+ if old_row is None or not self._is_row_visible(old_row) or not animate:
1566
+ self._row = row
1567
+ self._top, self._bottom = self._default_span(row)
1568
+ self.update()
1569
+ return
1570
+
1571
+ old_rect = self._row_rect(old_row)
1572
+ self._row = row
1573
+ self._new_row = row
1574
+ new_rect = self._row_rect(row)
1575
+ self._above = new_rect.center().y() < old_rect.center().y()
1576
+ self._default_top, self._default_bottom = self._default_span(row)
1577
+ self._frozen_top = self._top
1578
+ self._frozen_bottom = self._bottom
1579
+ self._phase = 0
1580
+
1581
+ if self._above:
1582
+ self._edge = "top"
1583
+ self._start_edge(self._top, old_rect.top())
1584
+ else:
1585
+ self._edge = "bottom"
1586
+ self._start_edge(self._bottom, old_rect.bottom())
1587
+
1588
+ def hide_indicator(self) -> None:
1589
+ self._anim.stop()
1590
+ self._slide_anim.stop()
1591
+ self._row = None
1592
+ self._anchor_row = None
1593
+ self.hide()
1594
+
1595
+ def is_animating(self) -> bool:
1596
+ return (self._anim.state() == QAbstractAnimation.Running or
1597
+ self._slide_anim.state() == QAbstractAnimation.Running)
1598
+
1599
+ def set_row_animated_from(self, row: QWidget | None, start_y: float) -> None:
1600
+ """把指示器从 start_y(本控件坐标)滑动到 row 的默认位置。"""
1601
+ self._anim.stop()
1602
+ self._slide_anim.stop()
1603
+ self._anchor_row = row
1604
+ if row is None or not self._is_row_visible(row):
1605
+ self._row = None
1606
+ self.hide()
1607
+ return
1608
+ self._row = row
1609
+ self._slide_to_top, self._slide_to_bottom = self._default_span(row)
1610
+ offset = float(start_y) - (self._slide_to_top + self._slide_to_bottom) / 2.0
1611
+ self._slide_from_top = self._slide_to_top + offset
1612
+ self._slide_from_bottom = self._slide_to_bottom + offset
1613
+ self._top = self._slide_from_top
1614
+ self._bottom = self._slide_from_bottom
1615
+ self.show()
1616
+ self.raise_()
1617
+ self.update()
1618
+ self._slide_anim.setStartValue(0.0)
1619
+ self._slide_anim.setEndValue(1.0)
1620
+ self._slide_anim.start()
1621
+
1622
+ def _on_slide_value(self, value) -> None:
1623
+ value = float(value)
1624
+ self._top = self._slide_from_top + (self._slide_to_top - self._slide_from_top) * value
1625
+ self._bottom = self._slide_from_bottom + (
1626
+ self._slide_to_bottom - self._slide_from_bottom
1627
+ ) * value
1628
+ self.update()
1629
+
1630
+ def refresh(self) -> None:
1631
+ anchor = self._anchor_row
1632
+ if anchor is None:
1633
+ self._row = None
1634
+ self.hide()
1635
+ return
1636
+ if self._anim.state() == QAbstractAnimation.Running:
1637
+ return
1638
+ if not self._is_row_visible(anchor):
1639
+ self._row = None
1640
+ self.hide()
1641
+ return
1642
+ self._row = anchor
1643
+ self._top, self._bottom = self._default_span(anchor)
1644
+ self.show()
1645
+ self.raise_()
1646
+ self.update()
1647
+
1648
+ # -- 动画 -------------------------------------------------------------
1649
+ def _start_edge(self, start: float, end: float) -> None:
1650
+ self._anim.stop()
1651
+ self._anim.setStartValue(float(start))
1652
+ self._anim.setEndValue(float(end))
1653
+ self._anim.start()
1654
+
1655
+ def _on_anim_value(self, value) -> None:
1656
+ value = float(value)
1657
+ if self._edge == "top":
1658
+ self._top = value
1659
+ else:
1660
+ self._bottom = value
1661
+ self.update()
1662
+
1663
+ def _on_anim_finished(self) -> None:
1664
+ if self._phase == 0:
1665
+ self._phase = 1
1666
+ if self._above:
1667
+ self._top = self._default_top
1668
+ self._bottom = self._row_rect(self._new_row).bottom()
1669
+ self._edge = "bottom"
1670
+ self._start_edge(self._bottom, self._default_bottom)
1671
+ else:
1672
+ self._top = self._row_rect(self._new_row).top()
1673
+ self._bottom = self._default_bottom
1674
+ self._edge = "top"
1675
+ self._start_edge(self._top, self._default_top)
1676
+ else:
1677
+ self._phase = 2
1678
+ if self._row is not None:
1679
+ self._top, self._bottom = self._default_span(self._row)
1680
+ else:
1681
+ self._top = self._default_top
1682
+ self._bottom = self._default_bottom
1683
+ self.update()
1684
+
1685
+ # -- 绘制 -------------------------------------------------------------
1686
+ def paintEvent(self, event) -> None:
1687
+ if self._row is None or self._bottom - self._top <= 0:
1688
+ return
1689
+ theme = self._theme()
1690
+ width = float(theme["indicatorWidth"])
1691
+ painter = QPainter(self)
1692
+ painter.setRenderHint(QPainter.Antialiasing, True)
1693
+ painter.setPen(Qt.NoPen)
1694
+ painter.setBrush(QColor(theme["indicatorColor"]))
1695
+ painter.drawRoundedRect(
1696
+ QRectF(INDICATOR_INSET, self._top, width,
1697
+ self._bottom - self._top),
1698
+ width / 2.0, width / 2.0,
1699
+ )
1700
+ painter.end()
1701
+
1702
+
1703
+ # ---------------------------------------------------------------------------
1704
+ # 内容区
1705
+ # ---------------------------------------------------------------------------
1706
+
1707
+
1708
+ class _LayoutHost(QWidget):
1709
+ """承载用户 QLayout 的容器,向滚动区透传 heightForWidth。"""
1710
+
1711
+ def heightForWidth(self, width: int) -> int:
1712
+ layout = self.layout()
1713
+ if layout is not None and layout.hasHeightForWidth():
1714
+ return layout.heightForWidth(width)
1715
+ return super().heightForWidth(width)
1716
+
1717
+ def sizeHint(self) -> QSize:
1718
+ layout = self.layout()
1719
+ if layout is not None and layout.hasHeightForWidth() and self.width() > 0:
1720
+ return QSize(max(1, layout.sizeHint().width()),
1721
+ max(1, layout.heightForWidth(self.width())))
1722
+ return super().sizeHint()
1723
+
1724
+
1725
+ class _ContentPage(QScrollArea):
1726
+ """一个菜单项对应的内容页(自带胶囊滚动条)。"""
1727
+
1728
+ def __init__(self, parent=None):
1729
+ super().__init__(parent)
1730
+ self.setFrameShape(QFrame.NoFrame)
1731
+ self.setWidgetResizable(True)
1732
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
1733
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
1734
+ self.setVerticalScrollBar(_CapsuleScrollBar(self))
1735
+ self.setAutoFillBackground(False)
1736
+ self.viewport().setAutoFillBackground(False)
1737
+ self.viewport().setStyleSheet("background: transparent;")
1738
+ self._body = QWidget()
1739
+ self._body.setAutoFillBackground(False)
1740
+ self._body_layout = QVBoxLayout(self._body)
1741
+ self._body_layout.setContentsMargins(0, 0, 0, 0)
1742
+ self._body_layout.setSpacing(0)
1743
+ self.setWidget(self._body)
1744
+ self.setStyleSheet("QScrollArea { background: transparent; border: none; }")
1745
+
1746
+ def body_layout(self) -> QVBoxLayout:
1747
+ return self._body_layout
1748
+
1749
+ def add_host(self, host: QWidget) -> None:
1750
+ if host.parent() is not self._body:
1751
+ host.setParent(self._body)
1752
+ if self._body_layout.indexOf(host) < 0:
1753
+ self._body_layout.addWidget(host)
1754
+
1755
+ def remove_host(self, host: QWidget) -> None:
1756
+ if self._body_layout.indexOf(host) >= 0:
1757
+ self._body_layout.removeWidget(host)
1758
+
1759
+
1760
+ class _ContentHost(QWidget):
1761
+ """内容页容器:负责页面定位与 64px 上滑动画。"""
1762
+
1763
+ def __init__(self, parent=None):
1764
+ super().__init__(parent)
1765
+ self.setAutoFillBackground(False)
1766
+ self._pages: list[_ContentPage] = []
1767
+ self._current: _ContentPage | None = None
1768
+ self._anim = QPropertyAnimation(self, b"pos", self)
1769
+
1770
+ def register_page(self, page: _ContentPage) -> None:
1771
+ if page not in self._pages:
1772
+ self._pages.append(page)
1773
+ page.setParent(self)
1774
+ page.hide()
1775
+
1776
+ def pages(self) -> list[_ContentPage]:
1777
+ return list(self._pages)
1778
+
1779
+ def remove_page(self, page: _ContentPage) -> None:
1780
+ if page in self._pages:
1781
+ self._pages.remove(page)
1782
+ if self._current is page:
1783
+ self._anim.stop()
1784
+ self._current = None
1785
+ page.hide()
1786
+ page.deleteLater()
1787
+
1788
+ def current_page(self) -> _ContentPage | None:
1789
+ return self._current
1790
+
1791
+ def show_page(self, page: _ContentPage | None, animate: bool = True) -> None:
1792
+ old = self._current
1793
+ if page is None:
1794
+ self._anim.stop()
1795
+ for candidate in self._pages:
1796
+ candidate.hide()
1797
+ self._current = None
1798
+ return
1799
+ if page is old:
1800
+ page.move(0, 0)
1801
+ page.show()
1802
+ page.raise_()
1803
+ return
1804
+
1805
+ self.register_page(page)
1806
+ self._current = page
1807
+ for candidate in self._pages:
1808
+ if candidate is not page:
1809
+ candidate.hide()
1810
+ page.setGeometry(0, 0, self.width(), self.height())
1811
+ page.show()
1812
+ page.raise_()
1813
+ if animate:
1814
+ page.move(0, 64)
1815
+ self._anim.stop()
1816
+ self._anim.setTargetObject(page)
1817
+ self._anim.setPropertyName(b"pos")
1818
+ self._anim.setDuration(ANIM_MS)
1819
+ self._anim.setEasingCurve(QEasingCurve.OutCubic)
1820
+ self._anim.setStartValue(QPoint(0, 64))
1821
+ self._anim.setEndValue(QPoint(0, 0))
1822
+ self._anim.start()
1823
+ else:
1824
+ self._anim.stop()
1825
+ page.move(0, 0)
1826
+
1827
+ def resizeEvent(self, event) -> None:
1828
+ super().resizeEvent(event)
1829
+ for page in self._pages:
1830
+ page.setGeometry(0, 0, self.width(), self.height())
1831
+ if self._current is not None:
1832
+ self._current.move(0, 0)
1833
+
1834
+
1835
+ class HighLightPanel(QWidget):
1836
+ """内容卡片:默认透明,让窗口背板的 BUBBLE_BACKGROUND_NORMAL 透出。
1837
+
1838
+ ``bg_color`` 仍可作为兜底纯色(QSS: ``qproperty-bg_color``);默认透明时
1839
+ 内容区直接使用 glasskit 的窗口玻璃。左上 8px 圆角仅在设置了底色时可见。
1840
+ """
1841
+
1842
+ BG = QColor("#272727")
1843
+ RADIUS = 8
1844
+ PADDING = 36
1845
+
1846
+ def __init__(self, parent=None):
1847
+ super().__init__(parent)
1848
+ self._bg_color = QColor(0, 0, 0, 0)
1849
+ self._radius = self.RADIUS
1850
+ self.setAutoFillBackground(False)
1851
+ self._layout = QVBoxLayout(self)
1852
+ self._base_top = self.PADDING - 6
1853
+ self._layout.setContentsMargins(
1854
+ self.PADDING, self._base_top, self.PADDING, self.PADDING - 3
1855
+ )
1856
+ self._layout.setSpacing(0)
1857
+ self._content_host = _ContentHost(self)
1858
+ self._layout.addWidget(self._content_host, 1)
1859
+
1860
+ def set_top_clearance(self, clearance: int) -> None:
1861
+ """为 Hide 模式下浮在内容上的顶栏预留空间。"""
1862
+ top = self._base_top + max(0, int(clearance))
1863
+ margins = self._layout.contentsMargins()
1864
+ if margins.top() != top:
1865
+ self._layout.setContentsMargins(
1866
+ margins.left(), top, margins.right(), margins.bottom()
1867
+ )
1868
+ self._layout.activate()
1869
+
1870
+ def content_host(self) -> _ContentHost:
1871
+ return self._content_host
1872
+
1873
+ def setContent(self, widget: QWidget):
1874
+ """兼容旧 API:直接放入一个控件。"""
1875
+ self._layout.addWidget(widget)
1876
+
1877
+ def get_bg_color(self) -> QColor:
1878
+ return self._bg_color
1879
+
1880
+ def set_bg_color(self, color):
1881
+ color = QColor(color)
1882
+ if color != self._bg_color:
1883
+ self._bg_color = color
1884
+ self.update()
1885
+
1886
+ bg_color = Property(QColor, get_bg_color, set_bg_color)
1887
+
1888
+ def get_corner_radius(self) -> int:
1889
+ return self._radius
1890
+
1891
+ def set_corner_radius(self, value):
1892
+ value = max(0, int(value))
1893
+ if value != self._radius:
1894
+ self._radius = value
1895
+ self.update()
1896
+
1897
+ cornerRadius = Property(int, get_corner_radius, set_corner_radius)
1898
+
1899
+ def paintEvent(self, event):
1900
+ if self._bg_color.alpha() <= 0:
1901
+ return
1902
+ painter = QPainter(self)
1903
+ painter.setRenderHint(QPainter.Antialiasing)
1904
+ r = self._radius
1905
+ rect = QRectF(self.rect())
1906
+ path = QPainterPath()
1907
+ path.moveTo(rect.left() + r, rect.top())
1908
+ path.lineTo(rect.right(), rect.top())
1909
+ path.lineTo(rect.right(), rect.bottom())
1910
+ path.lineTo(rect.left(), rect.bottom())
1911
+ path.lineTo(rect.left(), rect.top() + r)
1912
+ path.quadTo(rect.left(), rect.top(), rect.left() + r, rect.top())
1913
+ path.closeSubpath()
1914
+ painter.fillPath(path, self._bg_color)
1915
+ painter.end()
1916
+
1917
+
1918
+ # ---------------------------------------------------------------------------
1919
+ # 侧边栏 / 根画布 / 顶栏
1920
+ # ---------------------------------------------------------------------------
1921
+
1922
+
1923
+ class _Pane(QWidget):
1924
+ """侧边栏玻璃面板:正常玻璃或 Flyout 玻璃。"""
1925
+
1926
+ def __init__(self, navigation, parent=None):
1927
+ super().__init__(parent)
1928
+ self._nav = navigation
1929
+ self._flyout = False
1930
+ self._snapshot: QImage | None = None
1931
+ self._snapshot_origin = QPoint(0, 0)
1932
+ self._settings_wrapper: QWidget | None = None
1933
+ self._settings_row: _NavRow | None = None
1934
+ self._settings_divider: QWidget | None = None
1935
+
1936
+ self._layout = QVBoxLayout(self)
1937
+ self._layout.setContentsMargins(ROW_MARGIN_X, 48 + ROW_MARGIN_TOP,
1938
+ ROW_MARGIN_X, 0)
1939
+ self._layout.setSpacing(0)
1940
+
1941
+ self._scroll = QScrollArea(self)
1942
+ self._scroll.setFrameShape(QFrame.NoFrame)
1943
+ self._scroll.setWidgetResizable(True)
1944
+ self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
1945
+ # 滚动条不占布局宽度:菜单行始终占满,滚动条作为覆盖层画在行上面。
1946
+ self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
1947
+ self._scroll.setAutoFillBackground(False)
1948
+ self._scroll.viewport().setAutoFillBackground(False)
1949
+ self._scroll.viewport().setStyleSheet("background: transparent;")
1950
+ self._scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
1951
+
1952
+ self._menu = QWidget()
1953
+ self._menu.setAutoFillBackground(False)
1954
+ self._menu_layout = QVBoxLayout(self._menu)
1955
+ self._menu_layout.setContentsMargins(0, 0, 0, 0)
1956
+ self._menu_layout.setSpacing(0)
1957
+ self._menu_layout.addStretch(1)
1958
+ self._scroll.setWidget(self._menu)
1959
+ self._layout.addWidget(self._scroll, 1)
1960
+
1961
+ self._scrollbar_model = self._scroll.verticalScrollBar()
1962
+ self._scroll_overlay = _CapsuleScrollBar(self)
1963
+ self._scroll_overlay.setFocusPolicy(Qt.NoFocus)
1964
+ self._scroll_overlay.hide()
1965
+ self._scroll_syncing = False
1966
+ self._scrollbar_model.rangeChanged.connect(self._on_scroll_range_changed)
1967
+ self._scrollbar_model.valueChanged.connect(self._on_scroll_model_value)
1968
+ self._scroll_overlay.valueChanged.connect(self._on_scroll_overlay_value)
1969
+
1970
+ self._indicator = _IndicatorOverlay(self, self)
1971
+ self._indicator.raise_()
1972
+ self._scrollbar_model.valueChanged.connect(self._indicator.refresh)
1973
+
1974
+ # -- 菜单内容 ---------------------------------------------------------
1975
+ def add_menu_widget(self, widget: QWidget) -> None:
1976
+ index = max(0, self._menu_layout.count() - 1)
1977
+ self._menu_layout.insertWidget(index, widget)
1978
+
1979
+ def remove_menu_widget(self, widget: QWidget) -> None:
1980
+ self._menu_layout.removeWidget(widget)
1981
+
1982
+ def set_settings_widget(self, wrapper: QWidget, row: _NavRow) -> None:
1983
+ self._settings_wrapper = wrapper
1984
+ self._settings_row = row
1985
+ # 设置项固定在滚动区外:上方 1px 分割线,下方 6px 外边距。
1986
+ self._settings_divider = _SettingsDivider(self._nav, self)
1987
+ self._layout.addWidget(self._settings_divider)
1988
+ self._layout.addWidget(wrapper)
1989
+ margins = self._layout.contentsMargins()
1990
+ self._layout.setContentsMargins(
1991
+ margins.left(), margins.top(), margins.right(),
1992
+ SETTINGS_BOTTOM_MARGIN,
1993
+ )
1994
+ self._layout.activate()
1995
+ self._position_scroll_overlay()
1996
+
1997
+ def settings_widget(self) -> QWidget | None:
1998
+ return self._settings_wrapper
1999
+
2000
+ def set_header_height(self, height: int) -> None:
2001
+ margins = self._layout.contentsMargins()
2002
+ self._layout.setContentsMargins(margins.left(), height + ROW_MARGIN_TOP,
2003
+ margins.right(), margins.bottom())
2004
+ self._layout.activate()
2005
+ self._menu_layout.activate()
2006
+ self.updateGeometry()
2007
+ QTimer.singleShot(0, self._sync_scroll_overlay)
2008
+
2009
+ # -- 覆盖式滚动条 -----------------------------------------------------
2010
+ def _sync_scroll_overlay(self) -> None:
2011
+ if not hasattr(self, "_scroll_overlay"):
2012
+ return
2013
+ model = self._scrollbar_model
2014
+ overlay = self._scroll_overlay
2015
+ self._scroll_syncing = True
2016
+ try:
2017
+ overlay.setRange(model.minimum(), model.maximum())
2018
+ overlay.setPageStep(model.pageStep())
2019
+ overlay.setSingleStep(model.singleStep())
2020
+ overlay.setValue(model.value())
2021
+ finally:
2022
+ self._scroll_syncing = False
2023
+ self._position_scroll_overlay()
2024
+
2025
+ def _position_scroll_overlay(self) -> None:
2026
+ if not hasattr(self, "_scroll_overlay"):
2027
+ return
2028
+ top_left = self._scroll.mapTo(self, QPoint(0, 0))
2029
+ x = max(0, self.width() - ROW_MARGIN_X - SCROLLBAR_HIT)
2030
+ height = max(0, self._scroll.height())
2031
+ self._scroll_overlay.setGeometry(x, top_left.y(), SCROLLBAR_HIT, height)
2032
+ needed = self._scrollbar_model.maximum() > self._scrollbar_model.minimum()
2033
+ self._scroll_overlay.setVisible(bool(needed and self.isVisible()))
2034
+ if needed:
2035
+ self._scroll_overlay.raise_()
2036
+
2037
+ def _on_scroll_range_changed(self, _minimum: int, _maximum: int) -> None:
2038
+ if self._scroll_syncing:
2039
+ return
2040
+ self._sync_scroll_overlay()
2041
+
2042
+ def _on_scroll_model_value(self, value: int) -> None:
2043
+ if self._scroll_syncing:
2044
+ return
2045
+ self._scroll_syncing = True
2046
+ try:
2047
+ self._scroll_overlay.setValue(int(value))
2048
+ finally:
2049
+ self._scroll_syncing = False
2050
+
2051
+ def _on_scroll_overlay_value(self, value: int) -> None:
2052
+ if self._scroll_syncing:
2053
+ return
2054
+ self._scrollbar_model.setValue(int(value))
2055
+
2056
+ def showEvent(self, event) -> None:
2057
+ super().showEvent(event)
2058
+ self._sync_scroll_overlay()
2059
+
2060
+ # -- 状态 -------------------------------------------------------------
2061
+ def set_flyout(self, flyout: bool) -> None:
2062
+ flyout = bool(flyout)
2063
+ if flyout == self._flyout:
2064
+ return
2065
+ self._flyout = flyout
2066
+ self.update()
2067
+
2068
+ def set_snapshot(self, image: QImage | None, origin: QPoint) -> None:
2069
+ self._snapshot = image
2070
+ self._snapshot_origin = QPoint(origin)
2071
+ self.update()
2072
+
2073
+ def shift_snapshot(self, dx: int, dy: int) -> None:
2074
+ if self._snapshot is not None:
2075
+ self._snapshot_origin += QPoint(int(dx), int(dy))
2076
+ self.update()
2077
+
2078
+ def set_compact(self, compact: bool) -> None:
2079
+ if self._settings_row is not None:
2080
+ self._settings_row.set_compact(compact)
2081
+
2082
+ def refresh_theme(self) -> None:
2083
+ self._indicator.refresh()
2084
+ self._indicator.update()
2085
+ if self._settings_divider is not None:
2086
+ self._settings_divider.update()
2087
+ if hasattr(self, "_scroll_overlay"):
2088
+ self._scroll_overlay.update()
2089
+ for widget in self._menu.findChildren(QWidget):
2090
+ if isinstance(widget, (_SeparatorWidget, _SettingsDivider)):
2091
+ widget.update()
2092
+ self.update()
2093
+
2094
+ def menu_visible_rect(self) -> QRect:
2095
+ viewport = self._scroll.viewport()
2096
+ top_left = viewport.mapTo(self, QPoint(0, 0))
2097
+ return QRect(top_left, viewport.size())
2098
+
2099
+ def row_visible_for_indicator(self, row: QWidget | None) -> bool:
2100
+ if row is None or not row.isVisible():
2101
+ return False
2102
+ if self._settings_wrapper is not None and (
2103
+ row is self._settings_wrapper or
2104
+ self._settings_wrapper.isAncestorOf(row)):
2105
+ return True
2106
+ visible_rect = self.menu_visible_rect()
2107
+ center_global = row.mapToGlobal(
2108
+ QPoint(row.width() // 2, row.height() // 2)
2109
+ )
2110
+ center = self.mapFromGlobal(center_global)
2111
+ return visible_rect.contains(center)
2112
+
2113
+ def indicator(self) -> _IndicatorOverlay:
2114
+ return self._indicator
2115
+
2116
+ # -- 绘制 -------------------------------------------------------------
2117
+ def paintEvent(self, event) -> None:
2118
+ painter = QPainter(self)
2119
+ painter.setRenderHint(QPainter.Antialiasing, True)
2120
+ painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
2121
+ rect = QRectF(self.rect())
2122
+
2123
+ if self._flyout:
2124
+ style = FLYOUT_GLASS
2125
+ path = rounded_path(rect, style.radius)
2126
+ painter.save()
2127
+ painter.setClipPath(path)
2128
+ base = QColor(style.tint)
2129
+ base.setAlpha(int(round(255 * style.opacity / 100.0)))
2130
+ painter.fillRect(rect, base)
2131
+ if self._snapshot is not None and not self._snapshot.isNull():
2132
+ dpr = float(self._snapshot.devicePixelRatio() or 1.0)
2133
+ our_global = self.mapToGlobal(QPoint(0, 0))
2134
+ offset = self._snapshot_origin - our_global
2135
+ target = QRectF(offset.x(), offset.y(),
2136
+ self._snapshot.width() / dpr,
2137
+ self._snapshot.height() / dpr)
2138
+ painter.drawImage(target, self._snapshot)
2139
+ painter.restore()
2140
+ paint_frost_overlay(painter, rect, style, radius=style.radius,
2141
+ border=True, vignette=style.vignette)
2142
+ else:
2143
+ style = SIDEBAR_GLASS
2144
+ backdrop = self._nav.backdrop
2145
+ if backdrop is not None and self.isVisible():
2146
+ if backdrop.energy.active:
2147
+ painter.fillRect(rect, QColor(style.tint))
2148
+ else:
2149
+ global_rect = QRect(self.mapToGlobal(QPoint(0, 0)), self.size())
2150
+ backdrop.paint_material(painter, rect, global_rect, style)
2151
+ else:
2152
+ paint_frost_overlay(painter, rect, style, radius=0, border=False)
2153
+ painter.end()
2154
+
2155
+ def resizeEvent(self, event) -> None:
2156
+ super().resizeEvent(event)
2157
+ self._indicator.setGeometry(self.rect())
2158
+ self._indicator.refresh()
2159
+ self._sync_scroll_overlay()
2160
+
2161
+
2162
+ class _PaneShadow(QWidget):
2163
+ """侧边栏 Flyout 的柔和阴影层(预模糊,不压暗玻璃内部)。"""
2164
+
2165
+ def __init__(self, parent=None, radius: int = CORNER_RADIUS):
2166
+ super().__init__(parent)
2167
+ self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
2168
+ self.setAutoFillBackground(False)
2169
+ self._radius = int(radius)
2170
+ self._cache_key: tuple | None = None
2171
+ self._image: QImage | None = None
2172
+
2173
+ def set_radius(self, radius: int) -> None:
2174
+ radius = max(0, int(radius))
2175
+ if radius != self._radius:
2176
+ self._radius = radius
2177
+ self._cache_key = None
2178
+ self.update()
2179
+
2180
+ def _build_image(self) -> QImage | None:
2181
+ width, height = self.width(), self.height()
2182
+ if width <= 0 or height <= 0:
2183
+ return None
2184
+ dpr = float(self.devicePixelRatioF() or 1.0)
2185
+ key = (width, height, dpr, self._radius)
2186
+ if self._cache_key == key and self._image is not None:
2187
+ return self._image
2188
+
2189
+ image = QImage(int(width * dpr), int(height * dpr),
2190
+ QImage.Format.Format_RGBA8888)
2191
+ image.setDevicePixelRatio(dpr)
2192
+ image.fill(Qt.transparent)
2193
+ painter = QPainter(image)
2194
+ painter.setRenderHint(QPainter.Antialiasing, True)
2195
+ painter.setPen(Qt.NoPen)
2196
+ painter.setBrush(QColor(0, 0, 0, 165))
2197
+ inner = QRectF(self.rect()).adjusted(
2198
+ SHADOW_MARGIN, SHADOW_MARGIN, -SHADOW_MARGIN, -SHADOW_MARGIN
2199
+ )
2200
+ painter.drawRoundedRect(inner, self._radius, self._radius)
2201
+ painter.end()
2202
+
2203
+ blurred = blur_qimage(image, 12.0 * dpr)
2204
+ blurred.setDevicePixelRatio(dpr)
2205
+ # 挖掉面板内部,玻璃只叠在外侧柔和阴影上。
2206
+ erase = QPainter(blurred)
2207
+ erase.setRenderHint(QPainter.Antialiasing, True)
2208
+ erase.setCompositionMode(QPainter.CompositionMode_Clear)
2209
+ erase.setPen(Qt.NoPen)
2210
+ erase.setBrush(Qt.black)
2211
+ erase.drawRoundedRect(inner, self._radius, self._radius)
2212
+ erase.end()
2213
+
2214
+ self._cache_key = key
2215
+ self._image = blurred
2216
+ return blurred
2217
+
2218
+ def paintEvent(self, event) -> None:
2219
+ image = self._build_image()
2220
+ if image is None:
2221
+ return
2222
+ painter = QPainter(self)
2223
+ painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
2224
+ painter.drawImage(0, 0, image)
2225
+ painter.end()
2226
+
2227
+
2228
+ class _HeaderBar(QWidget):
2229
+ """汉堡 / 返回按钮容器;Compact 图标栏内纵向排列,其余横向排列。"""
2230
+
2231
+ def __init__(self, buttons: list[AnimatedIconButton], parent=None):
2232
+ super().__init__(parent)
2233
+ self._buttons = buttons
2234
+ self._vertical = False
2235
+ self._initialized = False
2236
+ for button in buttons:
2237
+ button.setParent(self)
2238
+ self.setAutoFillBackground(False)
2239
+ self.set_vertical(False)
2240
+
2241
+ def set_vertical(self, vertical: bool) -> None:
2242
+ vertical = bool(vertical)
2243
+ if self._initialized and vertical == self._vertical:
2244
+ return
2245
+ self._vertical = vertical
2246
+ self._initialized = True
2247
+ count = len(self._buttons)
2248
+ if count == 0:
2249
+ self.setFixedSize(0, 0)
2250
+ return
2251
+ width, height = HEADER_BUTTON_SIZE
2252
+ if vertical:
2253
+ for index, button in enumerate(self._buttons):
2254
+ button.move(HEADER_MARGIN,
2255
+ HEADER_MARGIN + index * (height + HEADER_MARGIN))
2256
+ self.setFixedSize(HEADER_MARGIN * 2 + width,
2257
+ HEADER_MARGIN * 2 + count * height +
2258
+ (count - 1) * HEADER_MARGIN)
2259
+ else:
2260
+ for index, button in enumerate(self._buttons):
2261
+ button.move(HEADER_MARGIN + index * (width + HEADER_MARGIN),
2262
+ HEADER_MARGIN)
2263
+ self.setFixedSize(HEADER_MARGIN * 2 + count * width +
2264
+ (count - 1) * HEADER_MARGIN,
2265
+ HEADER_MARGIN * 2 + height)
2266
+
2267
+ def recommended_size(self) -> QSize:
2268
+ return self.size()
2269
+
2270
+
2271
+ class _RootCanvas(QWidget):
2272
+ """窗口根画布:内容卡片、侧边栏、阴影、顶栏全部手动定位。"""
2273
+
2274
+ def __init__(self, navigation, parent=None):
2275
+ super().__init__(parent)
2276
+ self._nav = navigation
2277
+ self.setAutoFillBackground(False)
2278
+ self._content_card = HighLightPanel(self)
2279
+ self._shadow = _PaneShadow(self)
2280
+ self._pane = _Pane(navigation, self)
2281
+ self._header: _HeaderBar | None = None
2282
+
2283
+ self._pane_w = float(EXPANDED_WIDTH)
2284
+ self._pane_x = 0.0
2285
+ self._pane_y = 0.0
2286
+ self._pane_h = 0.0
2287
+ self._content_x = float(EXPANDED_WIDTH)
2288
+ self._flyout_mode = False
2289
+ self._base_width = COMPACT_WIDTH
2290
+
2291
+ self._anim_group: QParallelAnimationGroup | None = None
2292
+ self._finish_callback: Callable[[], None] | None = None
2293
+
2294
+ self._shadow.hide()
2295
+ self._layout_children()
2296
+
2297
+ # -- 属性动画 ---------------------------------------------------------
2298
+ def get_pane_width(self) -> float:
2299
+ return self._pane_w
2300
+
2301
+ def set_pane_width(self, value):
2302
+ self._pane_w = float(value)
2303
+ self._layout_children()
2304
+
2305
+ paneWidth = Property(float, get_pane_width, set_pane_width)
2306
+
2307
+ def get_pane_x(self) -> float:
2308
+ return self._pane_x
2309
+
2310
+ def set_pane_x(self, value):
2311
+ self._pane_x = float(value)
2312
+ self._layout_children()
2313
+
2314
+ paneX = Property(float, get_pane_x, set_pane_x)
2315
+
2316
+ def get_pane_y(self) -> float:
2317
+ return self._pane_y
2318
+
2319
+ def set_pane_y(self, value):
2320
+ self._pane_y = float(value)
2321
+ self._layout_children()
2322
+
2323
+ paneY = Property(float, get_pane_y, set_pane_y)
2324
+
2325
+ def get_pane_height(self) -> float:
2326
+ return self._pane_h
2327
+
2328
+ def set_pane_height(self, value):
2329
+ self._pane_h = float(value)
2330
+ self._layout_children()
2331
+
2332
+ paneHeight = Property(float, get_pane_height, set_pane_height)
2333
+
2334
+ def get_content_x(self) -> float:
2335
+ return self._content_x
2336
+
2337
+ def set_content_x(self, value):
2338
+ self._content_x = float(value)
2339
+ self._layout_children()
2340
+
2341
+ contentX = Property(float, get_content_x, set_content_x)
2342
+
2343
+ # -- 对外 -------------------------------------------------------------
2344
+ def content_card(self) -> HighLightPanel:
2345
+ return self._content_card
2346
+
2347
+ def pane(self) -> _Pane:
2348
+ return self._pane
2349
+
2350
+ def shadow(self) -> _PaneShadow:
2351
+ return self._shadow
2352
+
2353
+ def attach_header(self, header: _HeaderBar) -> None:
2354
+ self._header = header
2355
+ header.setParent(self)
2356
+ self._layout_children()
2357
+
2358
+ def set_base_width(self, width: int) -> None:
2359
+ self._base_width = int(width)
2360
+
2361
+ def base_width(self) -> int:
2362
+ return self._base_width
2363
+
2364
+ def set_flyout_mode(self, flyout: bool) -> None:
2365
+ self._flyout_mode = bool(flyout)
2366
+ if self._flyout_mode:
2367
+ self._shadow.show()
2368
+ self._shadow.raise_()
2369
+ self._pane.raise_()
2370
+ else:
2371
+ self._shadow.hide()
2372
+
2373
+ def animate_to(self, *, pane_width: float, pane_x: float, pane_y: float,
2374
+ pane_height: float, content_x: float, duration: int,
2375
+ flyout: bool, on_finished: Callable[[], None] | None = None) -> None:
2376
+ self.stop_animation()
2377
+ self.set_flyout_mode(flyout)
2378
+ targets = [
2379
+ ("paneWidth", self._pane_w, float(pane_width)),
2380
+ ("paneX", self._pane_x, float(pane_x)),
2381
+ ("paneY", self._pane_y, float(pane_y)),
2382
+ ("paneHeight", self._pane_h, float(pane_height)),
2383
+ ("contentX", self._content_x, float(content_x)),
2384
+ ]
2385
+ animations = []
2386
+ for name, start, end in targets:
2387
+ if abs(start - end) < 0.5:
2388
+ continue
2389
+ anim = QPropertyAnimation(self, name.encode("ascii"), self)
2390
+ anim.setDuration(max(0, int(duration)))
2391
+ anim.setEasingCurve(QEasingCurve.OutCubic)
2392
+ anim.setStartValue(start)
2393
+ anim.setEndValue(end)
2394
+ animations.append(anim)
2395
+
2396
+ self._finish_callback = on_finished
2397
+ if not animations or duration <= 0:
2398
+ self._pane_w = float(pane_width)
2399
+ self._pane_x = float(pane_x)
2400
+ self._pane_y = float(pane_y)
2401
+ self._pane_h = float(pane_height)
2402
+ self._content_x = float(content_x)
2403
+ self._layout_children()
2404
+ self._run_finish_callback()
2405
+ return
2406
+
2407
+ group = QParallelAnimationGroup(self)
2408
+ for anim in animations:
2409
+ group.addAnimation(anim)
2410
+ group.finished.connect(self._on_animation_finished)
2411
+ self._anim_group = group
2412
+ group.start()
2413
+
2414
+ def stop_animation(self) -> None:
2415
+ if self._anim_group is not None:
2416
+ self._anim_group.stop()
2417
+ self._anim_group.deleteLater()
2418
+ self._anim_group = None
2419
+ self._finish_callback = None
2420
+
2421
+ def _on_animation_finished(self) -> None:
2422
+ self._anim_group = None
2423
+ self._layout_children()
2424
+ self._run_finish_callback()
2425
+
2426
+ def _run_finish_callback(self) -> None:
2427
+ callback, self._finish_callback = self._finish_callback, None
2428
+ if callback is not None:
2429
+ callback()
2430
+
2431
+ # -- 布局 -------------------------------------------------------------
2432
+ def refresh_geometry(self) -> None:
2433
+ if self._pane_h <= 0:
2434
+ self._pane_h = float(self.height())
2435
+ self._layout_children()
2436
+
2437
+ def resizeEvent(self, event) -> None:
2438
+ super().resizeEvent(event)
2439
+ if self._anim_group is None:
2440
+ self._pane_h = float(self.height())
2441
+ if self._flyout_mode:
2442
+ self._pane_y = 4.0
2443
+ self._pane_h = float(self.height() - 8)
2444
+ self._layout_children()
2445
+
2446
+ def _layout_children(self) -> None:
2447
+ width = self.width()
2448
+ height = self.height()
2449
+ if height <= 0:
2450
+ return
2451
+
2452
+ pane_w = max(0.0, min(self._pane_w, float(width)))
2453
+ pane_h = self._pane_h if self._pane_h > 0 else float(height)
2454
+ pane_h = max(0.0, min(pane_h, float(height)))
2455
+ self._pane.setGeometry(round(self._pane_x), round(self._pane_y),
2456
+ round(pane_w), round(pane_h))
2457
+ if pane_w < 1:
2458
+ self._pane.hide()
2459
+ else:
2460
+ self._pane.show()
2461
+
2462
+ content_x = max(0.0, min(self._content_x, float(width)))
2463
+ self._content_card.setGeometry(round(content_x), 0,
2464
+ max(0, width - round(content_x)), height)
2465
+
2466
+ if self._flyout_mode:
2467
+ self._shadow.setGeometry(
2468
+ round(self._pane_x) - SHADOW_MARGIN,
2469
+ round(self._pane_y) - SHADOW_MARGIN,
2470
+ round(pane_w) + SHADOW_MARGIN * 2,
2471
+ round(pane_h) + SHADOW_MARGIN * 2,
2472
+ )
2473
+ self._shadow.show()
2474
+ self._shadow.raise_()
2475
+ else:
2476
+ self._shadow.hide()
2477
+
2478
+ if self._header is not None:
2479
+ self._header.move(HEADER_MARGIN, HEADER_MARGIN)
2480
+ self._header.raise_()
2481
+
2482
+ self._content_card.lower()
2483
+ if self._flyout_mode:
2484
+ self._shadow.raise_()
2485
+ self._pane.raise_()
2486
+ if self._header is not None:
2487
+ self._header.raise_()
2488
+
2489
+
2490
+ # ---------------------------------------------------------------------------
2491
+ # 窄模式子菜单 Flyout
2492
+ # ---------------------------------------------------------------------------
2493
+
2494
+
2495
+ class _FlyoutSurface(QWidget):
2496
+ """Flyout 玻璃表面 + 子菜单行 + 指示器。"""
2497
+
2498
+ def __init__(self, navigation, parent=None):
2499
+ super().__init__(parent)
2500
+ self._nav = navigation
2501
+ self._snapshot: QImage | None = None
2502
+ self._snapshot_origin = QPoint(0, 0)
2503
+ self.setAutoFillBackground(False)
2504
+ self._layout = QVBoxLayout(self)
2505
+ self._layout.setContentsMargins(
2506
+ FLYOUT_PADDING_X, FLYOUT_PADDING_Y,
2507
+ FLYOUT_PADDING_X, FLYOUT_PADDING_Y,
2508
+ )
2509
+ self._layout.setSpacing(FLYOUT_ITEM_GAP)
2510
+ self._layout.addStretch(1)
2511
+ self._rows: list[_NavRow] = []
2512
+ self._indicator = _IndicatorOverlay(self, self)
2513
+ self._indicator.raise_()
2514
+
2515
+ def add_row(self, row: _NavRow) -> None:
2516
+ self._rows.append(row)
2517
+ row.setParent(self)
2518
+ row.setFixedHeight(FLYOUT_ROW_HEIGHT)
2519
+ self._layout.insertWidget(max(0, self._layout.count() - 1), row)
2520
+
2521
+ def refresh_theme(self) -> None:
2522
+ self._indicator.refresh()
2523
+ self._indicator.update()
2524
+ for row in self._rows:
2525
+ row.refresh_content()
2526
+ self.update()
2527
+
2528
+ def set_snapshot(self, image: QImage | None, origin: QPoint) -> None:
2529
+ self._snapshot = image
2530
+ self._snapshot_origin = QPoint(origin)
2531
+ self.update()
2532
+
2533
+ def shift_snapshot(self, dx: int, dy: int) -> None:
2534
+ """主窗口移动时同步玻璃快照的全局原点。"""
2535
+ if self._snapshot is not None:
2536
+ self._snapshot_origin += QPoint(int(dx), int(dy))
2537
+ self.update()
2538
+
2539
+ def menu_visible_rect(self) -> QRect:
2540
+ return self.rect()
2541
+
2542
+ def row_visible_for_indicator(self, row: QWidget | None) -> bool:
2543
+ if row is None or not row.isVisible():
2544
+ return False
2545
+ center_global = row.mapToGlobal(
2546
+ QPoint(row.width() // 2, row.height() // 2)
2547
+ )
2548
+ return self.rect().contains(self.mapFromGlobal(center_global))
2549
+
2550
+ def indicator(self) -> _IndicatorOverlay:
2551
+ return self._indicator
2552
+
2553
+ def sizeHint(self) -> QSize:
2554
+ hint = self._layout.sizeHint()
2555
+ return QSize(FLYOUT_WIDTH, max(1, hint.height()))
2556
+
2557
+ def paintEvent(self, event) -> None:
2558
+ painter = QPainter(self)
2559
+ painter.setRenderHint(QPainter.Antialiasing, True)
2560
+ painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
2561
+ rect = QRectF(self.rect())
2562
+ style = FLYOUT_FOLD_GLASS
2563
+ radius = float(self._nav._theme["flyoutRadius"])
2564
+ path = rounded_path(rect, radius)
2565
+ painter.save()
2566
+ painter.setClipPath(path)
2567
+ base = QColor(style.tint)
2568
+ base.setAlpha(int(round(255 * style.opacity / 100.0)))
2569
+ painter.fillRect(rect, base)
2570
+
2571
+ # Flyout 可能伸出主窗口:先用桌面/壁纸玻璃铺底,
2572
+ # 窗口内的部分再由主窗口快照覆盖,避免左侧出现纯色条。
2573
+ backdrop = self._nav.backdrop
2574
+ our_global = self.mapToGlobal(QPoint(0, 0))
2575
+ if backdrop is not None:
2576
+ global_rect = QRect(our_global, self.size())
2577
+ try:
2578
+ image, target = backdrop.source.processed_region(
2579
+ global_rect, style.blur, style.brightness, style.saturation
2580
+ )
2581
+ except Exception:
2582
+ image, target = None, QRectF()
2583
+ if image is not None and not image.isNull():
2584
+ target = target.translated(-our_global.x(), -our_global.y())
2585
+ painter.drawImage(target, image)
2586
+
2587
+ if self._snapshot is not None and not self._snapshot.isNull():
2588
+ dpr = float(self._snapshot.devicePixelRatio() or 1.0)
2589
+ offset = self._snapshot_origin - our_global
2590
+ target = QRectF(offset.x(), offset.y(),
2591
+ self._snapshot.width() / dpr,
2592
+ self._snapshot.height() / dpr)
2593
+ painter.drawImage(target, self._snapshot)
2594
+ painter.restore()
2595
+ paint_frost_overlay(painter, rect, style, radius=radius,
2596
+ border=True, vignette=style.vignette)
2597
+ painter.end()
2598
+
2599
+ def resizeEvent(self, event) -> None:
2600
+ super().resizeEvent(event)
2601
+ self._indicator.setGeometry(self.rect())
2602
+ self._indicator.refresh()
2603
+
2604
+
2605
+ class _SubmenuFlyout(QWidget):
2606
+ """窄模式下的子菜单浮窗:渐显 + 右移;失焦/点击外部关闭。"""
2607
+
2608
+ def __init__(self, navigation, fold: NavFoldItem):
2609
+ super().__init__(None)
2610
+ self._nav = navigation
2611
+ self._fold = fold
2612
+ self.setWindowFlags(
2613
+ Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint |
2614
+ Qt.NoDropShadowWindowHint
2615
+ )
2616
+ self.setAttribute(Qt.WA_TranslucentBackground, True)
2617
+ self.setAttribute(Qt.WA_ShowWithoutActivating, True)
2618
+ self.setAttribute(Qt.WA_NoSystemBackground, True)
2619
+
2620
+ self._shadow = _PaneShadow(
2621
+ self, radius=int(navigation._theme["flyoutRadius"])
2622
+ )
2623
+ self._surface = _FlyoutSurface(navigation, self)
2624
+ self._surface.raise_()
2625
+ self._row_by_child: dict[NavItem, _NavRow] = {}
2626
+ for child in fold.children():
2627
+ # 子菜单不叠加 CHILD_INDENT,按顶层菜单的 12px 内容边距排版。
2628
+ row = _NavRow(child, compact=False, child=False,
2629
+ content_margin=FLYOUT_CONTENT_MARGIN,
2630
+ parent=self._surface)
2631
+ row.pressed.connect(child.pressed.emit)
2632
+ row.released.connect(child.released.emit)
2633
+ row.clicked.connect(
2634
+ lambda child=child: self._on_child_clicked(child)
2635
+ )
2636
+ self._surface.add_row(row)
2637
+ self._row_by_child[child] = row
2638
+
2639
+ self._anim_pos: QPropertyAnimation | None = None
2640
+ self._anim_opacity: QPropertyAnimation | None = None
2641
+ self._closing = False
2642
+
2643
+ def surface(self) -> _FlyoutSurface:
2644
+ return self._surface
2645
+
2646
+ def refresh_theme(self) -> None:
2647
+ self._shadow.set_radius(int(self._nav._theme["flyoutRadius"]))
2648
+ self._surface.refresh_theme()
2649
+
2650
+ def fold(self) -> NavFoldItem:
2651
+ return self._fold
2652
+
2653
+ def row_for(self, child: NavItem) -> _NavRow | None:
2654
+ return self._row_by_child.get(child)
2655
+
2656
+ def _on_child_clicked(self, child: NavItem) -> None:
2657
+ child.clicked.emit()
2658
+ self._nav._activate_child(child, from_submenu=True)
2659
+
2660
+ # -- 展示 -------------------------------------------------------------
2661
+ def _final_geometry(self) -> QRect:
2662
+ surface_hint = self._surface.sizeHint()
2663
+ panel_w = FLYOUT_WIDTH
2664
+ panel_h = max(FLYOUT_ROW_HEIGHT + FLYOUT_PADDING_Y * 2,
2665
+ surface_hint.height())
2666
+ window_w = panel_w + SHADOW_MARGIN * 2
2667
+ window_h = panel_h + SHADOW_MARGIN * 2
2668
+
2669
+ main_global = self._nav.mapToGlobal(QPoint(0, 0))
2670
+ # 玻璃面板左边界 = 主窗口左边界 + 50px(外层窗口还要再让出阴影边距)。
2671
+ target_x = main_global.x() + FLYOUT_OFFSET_X - SHADOW_MARGIN
2672
+
2673
+ # 第一个列表项的中线与展开项的中线对齐;空间不足时也不自动挪位置。
2674
+ first_center = FLYOUT_PADDING_Y + FLYOUT_ROW_HEIGHT / 2.0
2675
+ row = self._fold._row
2676
+ if row is not None:
2677
+ row_center = row.mapToGlobal(QPoint(0, row.height() // 2)).y()
2678
+ surface_top = row_center - first_center
2679
+ target_y = round(surface_top) - SHADOW_MARGIN
2680
+ else:
2681
+ target_y = main_global.y() + 8 - SHADOW_MARGIN
2682
+
2683
+ return QRect(target_x, target_y, window_w, window_h)
2684
+
2685
+ def _layout_children(self) -> None:
2686
+ self._shadow.setGeometry(self.rect())
2687
+ self._surface.setGeometry(SHADOW_MARGIN, SHADOW_MARGIN,
2688
+ self.width() - SHADOW_MARGIN * 2,
2689
+ self.height() - SHADOW_MARGIN * 2)
2690
+ self._shadow.lower()
2691
+ self._surface.raise_()
2692
+
2693
+ def show_flyout(self) -> None:
2694
+ for anim in (self._anim_pos, self._anim_opacity):
2695
+ if anim is not None:
2696
+ anim.stop()
2697
+ snapshot = self._nav._grab_window_snapshot(FLYOUT_FOLD_GLASS)
2698
+ if snapshot is not None:
2699
+ self._surface.set_snapshot(snapshot[0], snapshot[1])
2700
+ final = self._final_geometry()
2701
+ self.setGeometry(final)
2702
+ self._layout_children()
2703
+ self.setWindowOpacity(0.0)
2704
+ self.move(final.x() - FLYOUT_OFFSET_X, final.y())
2705
+ self.show()
2706
+ self.raise_()
2707
+
2708
+ self._closing = False
2709
+ self._anim_pos = QPropertyAnimation(self, b"pos", self)
2710
+ self._anim_pos.setDuration(ANIM_MS)
2711
+ self._anim_pos.setEasingCurve(QEasingCurve.OutCubic)
2712
+ self._anim_pos.setStartValue(QPoint(final.x() - FLYOUT_OFFSET_X, final.y()))
2713
+ self._anim_pos.setEndValue(final.topLeft())
2714
+ self._anim_pos.start()
2715
+
2716
+ self._anim_opacity = QPropertyAnimation(self, b"windowOpacity", self)
2717
+ self._anim_opacity.setDuration(ANIM_MS)
2718
+ self._anim_opacity.setStartValue(0.0)
2719
+ self._anim_opacity.setEndValue(1.0)
2720
+ self._anim_opacity.start()
2721
+
2722
+ self._setup_indicator()
2723
+
2724
+ def _setup_indicator(self) -> None:
2725
+ self._surface.indicator().hide_indicator()
2726
+ if self._fold.layout is not None:
2727
+ return
2728
+ child = self._nav._selected_child
2729
+ if child is None or child._fold is not self._fold:
2730
+ return
2731
+ row = self._row_by_child.get(child)
2732
+ if row is None:
2733
+ return
2734
+ self._surface.indicator().set_row_animated_from(row, self._fold_row_y())
2735
+
2736
+ def _fold_row_y(self) -> float:
2737
+ row = self._fold._row
2738
+ if row is None:
2739
+ return 0.0
2740
+ top_left = row.mapToGlobal(QPoint(0, 0))
2741
+ surface_top = self._surface.mapToGlobal(QPoint(0, 0))
2742
+ return float(top_left.y() - surface_top.y() + row.height() // 2)
2743
+
2744
+ def close_flyout(self, animate: bool = True) -> None:
2745
+ if not self.isVisible() or self._closing:
2746
+ return
2747
+ self._closing = True
2748
+ for anim in (self._anim_pos, self._anim_opacity):
2749
+ if anim is not None:
2750
+ anim.stop()
2751
+ if not animate:
2752
+ self.hide()
2753
+ self._nav._on_submenu_closed(self)
2754
+ return
2755
+ start = self.geometry()
2756
+ self._anim_pos = QPropertyAnimation(self, b"pos", self)
2757
+ self._anim_pos.setDuration(INDICATOR_STEP_MS)
2758
+ self._anim_pos.setEasingCurve(QEasingCurve.InCubic)
2759
+ self._anim_pos.setStartValue(start.topLeft())
2760
+ self._anim_pos.setEndValue(QPoint(start.x() - FLYOUT_OFFSET_X, start.y()))
2761
+ self._anim_pos.start()
2762
+
2763
+ self._anim_opacity = QPropertyAnimation(self, b"windowOpacity", self)
2764
+ self._anim_opacity.setDuration(INDICATOR_STEP_MS)
2765
+ self._anim_opacity.setStartValue(self.windowOpacity())
2766
+ self._anim_opacity.setEndValue(0.0)
2767
+ self._anim_opacity.finished.connect(self._finish_close)
2768
+ self._anim_opacity.start()
2769
+
2770
+ def _finish_close(self) -> None:
2771
+ self.hide()
2772
+ self._nav._on_submenu_closed(self)
2773
+
2774
+ def contains_widget(self, widget: QWidget | None) -> bool:
2775
+ if widget is None:
2776
+ return False
2777
+ return widget is self or self.isAncestorOf(widget)
2778
+
2779
+ def move_with_window(self, dx: int, dy: int) -> None:
2780
+ """主窗口拖动时,Flyout 保持相对位置一起移动。"""
2781
+ if not self.isVisible() or self._closing or (not dx and not dy):
2782
+ return
2783
+ if (self._anim_pos is not None and
2784
+ self._anim_pos.state() == QAbstractAnimation.Running):
2785
+ self._anim_pos.stop()
2786
+ self._anim_pos = None
2787
+ self.move(self.pos() + QPoint(int(dx), int(dy)))
2788
+ self._surface.shift_snapshot(dx, dy)
2789
+
2790
+ def resizeEvent(self, event) -> None:
2791
+ super().resizeEvent(event)
2792
+ self._layout_children()
2793
+
2794
+
2795
+ # ---------------------------------------------------------------------------
2796
+ # NavigationView
2797
+ # ---------------------------------------------------------------------------
2798
+
2799
+
2800
+ class NavigationView(GlassWindowBase):
2801
+ """统一托管整个窗口的导航视图。
2802
+
2803
+ ``show_pane`` 控制汉堡按钮(需求中 pane 指代汉堡按钮,而不是侧栏显隐);
2804
+ ``show_back`` 控制返回按钮;``show_settings`` 控制内置设置项。
2805
+
2806
+ 示例::
2807
+
2808
+ class DemoWindow(NavigationView):
2809
+ def __init__(self):
2810
+ super().__init__()
2811
+ self.addNavItem("\\uE80F", "主页", layout)
2812
+ self.setSelectedNavItem(0)
2813
+ """
2814
+
2815
+ selectedChanged = Signal(int)
2816
+ paneStateChanged = Signal(bool, object, object, bool)
2817
+
2818
+ # -- QSS 可覆盖的主题(默认值见 THEME_DEFAULTS) ---------------------
2819
+ selectedBg = _theme_qcolor("selectedBg")
2820
+ selectedHoverBg = _theme_qcolor("selectedHoverBg")
2821
+ selectedPressedBg = _theme_qcolor("selectedPressedBg")
2822
+ hoverBg = _theme_qcolor("hoverBg")
2823
+ pressedBg = _theme_qcolor("pressedBg")
2824
+ textColor = _theme_qcolor("textColor")
2825
+ pressedText = _theme_qcolor("pressedText")
2826
+ separatorColor = _theme_qcolor("separatorColor")
2827
+ dividerColor = _theme_qcolor("dividerColor")
2828
+ indicatorColor = _theme_qcolor("indicatorColor")
2829
+ indicatorWidth = _theme_int("indicatorWidth")
2830
+ indicatorHeight = _theme_int("indicatorHeight")
2831
+ navItemRadius = _theme_int("navItemRadius")
2832
+ flyoutRadius = _theme_int("flyoutRadius")
2833
+ flyoutItemRadius = _theme_int("flyoutItemRadius")
2834
+
2835
+ def __init__(self, show_pane: bool = True, show_back: bool = True,
2836
+ show_settings: bool = True, hide_threshold: int = 700,
2837
+ compact_threshold: int = 1000, MinWidth: int = 0,
2838
+ MaxWidth: int = 0, MinHeight: int = 0, MaxHeight: int = 0):
2839
+ super().__init__()
2840
+ # 内容区使用 glasskit 自带的全窗口玻璃;侧边栏/飞窗各自覆盖。
2841
+ self.setBrush(BUBBLE_BACKGROUND_NORMAL)
2842
+ self.setWindowTitle("NavigationView")
2843
+ self._theme = dict(THEME_DEFAULTS)
2844
+ self._ready = False
2845
+
2846
+ self._show_pane = bool(show_pane)
2847
+ self._show_back = bool(show_back)
2848
+ self._show_settings = bool(show_settings)
2849
+ self._hide_threshold = max(0, int(hide_threshold))
2850
+ self._compact_threshold = max(0, int(compact_threshold))
2851
+ if self._compact_threshold < self._hide_threshold:
2852
+ warnings.warn("compact_threshold 小于 hide_threshold,已按 hide_threshold 处理",
2853
+ RuntimeWarning, stacklevel=2)
2854
+ self._compact_threshold = self._hide_threshold
2855
+
2856
+ # -- 模型与状态 ---------------------------------------------------
2857
+ self._menu_entries: list = []
2858
+ self._nav_items: list[NavItem] = []
2859
+ self._fold_items: list[NavFoldItem] = []
2860
+ self._selected_top = None
2861
+ self._selected_child = None
2862
+ self._settings_selected = False
2863
+ self._last_selected_index = -1
2864
+ self._last_selected_key = None
2865
+ self._history: list[dict] = []
2866
+ self._history_limit = HISTORY_LIMIT
2867
+ self._remembered_folds: set[NavFoldItem] = set()
2868
+
2869
+ self._pane_state = PaneStateEnum.Auto
2870
+ self._show_inline = PaneShowInlineEnum.Auto
2871
+ self._effective_state: PaneStateEnum | None = None
2872
+ self._presentation_flyout = False
2873
+ self._last_base: PaneStateEnum | None = None
2874
+ self._last_state_tuple: tuple | None = None
2875
+ self._was_visually_expanded: bool | None = None
2876
+ self._pane_open_inline = True
2877
+ self._pane_flyout_open = False
2878
+
2879
+ self._submenu: _SubmenuFlyout | None = None
2880
+ self._app_filter_installed = False
2881
+ self._content_revision = 0
2882
+ self._last_clearance = 0
2883
+ self._pane_snapshot_cache: tuple | None = None
2884
+ self._window_snapshot_cache: tuple | None = None
2885
+ self._last_window_pos = QPoint(0, 0)
2886
+ self._pane_snapshot_timer = QTimer(self)
2887
+ self._pane_snapshot_timer.setSingleShot(True)
2888
+ self._pane_snapshot_timer.setInterval(ANIM_MS + 30)
2889
+ self._pane_snapshot_timer.timeout.connect(
2890
+ self._capture_pane_snapshot_if_needed
2891
+ )
2892
+
2893
+ self._layout_owner: dict[int, object] = {}
2894
+ self._layout_registry: dict[int, tuple[object, QWidget]] = {}
2895
+ self._layout_pool = QWidget(self)
2896
+ self._layout_pool.hide()
2897
+
2898
+ # -- 界面 ---------------------------------------------------------
2899
+ self._root = _RootCanvas(self)
2900
+ self.content_layout.addWidget(self._root)
2901
+ self._content_card = self._root.content_card()
2902
+ self._content_host = self._content_card.content_host()
2903
+ self._pane = self._root.pane()
2904
+
2905
+ buttons: list[AnimatedIconButton] = []
2906
+ self._hamburger: AnimatedIconButton | None = None
2907
+ self._back: AnimatedIconButton | None = None
2908
+ if self._show_pane:
2909
+ self._hamburger = AnimatedIconButton("hamburger", parent=self._root)
2910
+ self._hamburger.clicked.connect(self._toggle_pane)
2911
+ buttons.append(self._hamburger)
2912
+ if self._show_back:
2913
+ self._back = AnimatedIconButton("back", parent=self._root)
2914
+ self._back.clicked.connect(self._go_back)
2915
+ self._back.setEnabled(False)
2916
+ buttons.append(self._back)
2917
+ self._header = _HeaderBar(buttons, self._root)
2918
+ self._root.attach_header(self._header)
2919
+ if not buttons:
2920
+ self._header.hide()
2921
+
2922
+ self._settings_item: NavItem | None = None
2923
+ self._settings_row: _NavRow | None = None
2924
+ if self._show_settings:
2925
+ self._settings_item = NavItem(self, SETTINGS_GLYPH, "设置",
2926
+ layout=None, settings=True)
2927
+ self._settings_item.clicked.connect(self._activate_settings)
2928
+ self._settings_row = self._create_row(self._settings_item, child=False)
2929
+ wrapper = _wrap_row(self._settings_row)
2930
+ self._pane.set_settings_widget(wrapper, self._settings_row)
2931
+
2932
+ self._apply_size_limits(MinWidth, MaxWidth, MinHeight, MaxHeight)
2933
+ self._sync_app_filter()
2934
+
2935
+ self.resize(1180, 760)
2936
+ self._ready = True
2937
+ self._reset_open_flags()
2938
+ self._apply_pane_state(manual=False, animate=False, force=True)
2939
+ self._update_back_enabled()
2940
+
2941
+ # ------------------------------------------------------------------
2942
+ # 构造辅助
2943
+ # ------------------------------------------------------------------
2944
+ def _apply_size_limits(self, min_w: int, max_w: int,
2945
+ min_h: int, max_h: int) -> None:
2946
+ min_w = max(0, int(min_w))
2947
+ max_w = max(0, int(max_w))
2948
+ min_h = max(0, int(min_h))
2949
+ max_h = max(0, int(max_h))
2950
+ if max_w > 0 and min_w > max_w:
2951
+ warnings.warn("MinWidth 大于 MaxWidth,已夹紧", RuntimeWarning, stacklevel=2)
2952
+ min_w = max_w
2953
+ if max_h > 0 and min_h > max_h:
2954
+ warnings.warn("MinHeight 大于 MaxHeight,已夹紧", RuntimeWarning, stacklevel=2)
2955
+ min_h = max_h
2956
+ self.setMinimumWidth(min_w)
2957
+ self.setMaximumWidth(max_w if max_w > 0 else _QWIDGETSIZE_MAX)
2958
+ self.setMinimumHeight(min_h)
2959
+ self.setMaximumHeight(max_h if max_h > 0 else _QWIDGETSIZE_MAX)
2960
+
2961
+ def _refresh_theme(self) -> None:
2962
+ """QSS 主题变化后刷新所有自绘控件。"""
2963
+ if not hasattr(self, "_theme"):
2964
+ return
2965
+ for item in getattr(self, "_nav_items", ()):
2966
+ if getattr(item, "isValid", lambda: False)() and item._row is not None:
2967
+ item._row.refresh_content()
2968
+ for fold in getattr(self, "_fold_items", ()):
2969
+ if not getattr(fold, "isValid", lambda: False)():
2970
+ continue
2971
+ if fold._row is not None:
2972
+ fold._row.refresh_content()
2973
+ for child in fold._children:
2974
+ if child.isValid() and child._row is not None:
2975
+ child._row.refresh_content()
2976
+ if getattr(self, "_settings_row", None) is not None:
2977
+ self._settings_row.refresh_content()
2978
+ pane = getattr(self, "_pane", None)
2979
+ if pane is not None:
2980
+ pane.refresh_theme()
2981
+ card = getattr(self, "_content_card", None)
2982
+ if card is not None:
2983
+ card.update()
2984
+ submenu = getattr(self, "_submenu", None)
2985
+ if submenu is not None:
2986
+ submenu.refresh_theme()
2987
+
2988
+ def _create_row(self, item, child: bool = False) -> _NavRow:
2989
+ row: _NavRow
2990
+ if isinstance(item, NavFoldItem):
2991
+ row = _FoldRow(item, compact=not self._visually_expanded(), child=child)
2992
+ else:
2993
+ row = _NavRow(item, compact=not self._visually_expanded(), child=child)
2994
+ row.pressed.connect(item.pressed.emit)
2995
+ row.released.connect(item.released.emit)
2996
+ row.clicked.connect(item.clicked.emit)
2997
+ item._row = row
2998
+ return row
2999
+
3000
+ def _build_top_item(self, item) -> None:
3001
+ if isinstance(item, NavFoldItem):
3002
+ row = self._create_row(item)
3003
+ group = _FoldGroup(item, row)
3004
+ item._group = group
3005
+ group.body().heightChanged.connect(self._on_layout_shifted)
3006
+ item.clicked.connect(lambda item=item: self._activate_fold(item))
3007
+ self._pane.add_menu_widget(group)
3008
+ else:
3009
+ row = self._create_row(item)
3010
+ wrapper = _wrap_row(row)
3011
+ item._wrapper = wrapper
3012
+ item.clicked.connect(lambda item=item: self._activate_nav_item(item))
3013
+ self._pane.add_menu_widget(wrapper)
3014
+ item._row.set_compact(not self._visually_expanded())
3015
+
3016
+ # ------------------------------------------------------------------
3017
+ # 公共 API:添加 / 修改 / 删除
3018
+ # ------------------------------------------------------------------
3019
+ def addNavItem(self, fonticon: str, name: str, layout=None) -> NavItem:
3020
+ """添加普通菜单项;返回 NavItem。"""
3021
+ item = NavItem(self, fonticon, name, layout=None)
3022
+ self._nav_items.append(item)
3023
+ self._menu_entries.append(item)
3024
+ self._build_top_item(item)
3025
+ self._set_item_layout(item, layout)
3026
+ self._refresh_indices()
3027
+ return item
3028
+
3029
+ def addNavFoldItem(self, fonticon: str, name: str, layout=None) -> NavFoldItem:
3030
+ """添加折叠菜单项;layout 可选,有 layout 时点击标题会同时展开并切换内容。"""
3031
+ fold = NavFoldItem(self, fonticon, name, layout=None)
3032
+ self._fold_items.append(fold)
3033
+ self._menu_entries.append(fold)
3034
+ self._build_top_item(fold)
3035
+ self._set_item_layout(fold, layout)
3036
+ self._refresh_indices()
3037
+ return fold
3038
+
3039
+ def _add_child(self, fold: NavFoldItem, fonticon: str, name: str,
3040
+ layout) -> NavItem:
3041
+ if not fold.isValid():
3042
+ raise RuntimeError("该 NavFoldItem 已被移除")
3043
+ if fold._group is None:
3044
+ raise RuntimeError("折叠菜单尚未完成构建")
3045
+ child = NavItem(self, fonticon, name, layout=None, fold=fold)
3046
+ fold._children.append(child)
3047
+ row = self._create_row(child, child=True)
3048
+ child.clicked.connect(lambda child=child: self._activate_child(child))
3049
+ fold._group.add_child(row)
3050
+ self._set_item_layout(child, layout)
3051
+ self._refresh_indices()
3052
+ return child
3053
+
3054
+ def addSeparator(self, text: str):
3055
+ entry = _SeparatorEntry(text)
3056
+ widget = _SeparatorWidget(text, navigation=self)
3057
+ entry.widget = widget
3058
+ widget.heightChanged.connect(self._on_layout_shifted)
3059
+ self._menu_entries.append(entry)
3060
+ self._pane.add_menu_widget(widget)
3061
+ widget.set_compact(not self._visually_expanded(), animate=False)
3062
+ return widget
3063
+
3064
+ def addSeparateLine(self):
3065
+ """添加一条与设置项上方同款的 1px 分割线。"""
3066
+ entry = _LineEntry()
3067
+ line = _SettingsDivider(self)
3068
+ wrapper = QWidget()
3069
+ wrapper.setFixedHeight(SETTINGS_DIVIDER_HEIGHT + ROW_MARGIN_TOP * 2)
3070
+ layout = QVBoxLayout(wrapper)
3071
+ layout.setContentsMargins(0, ROW_MARGIN_TOP, 0, ROW_MARGIN_TOP)
3072
+ layout.setSpacing(0)
3073
+ layout.addWidget(line)
3074
+ entry.widget = wrapper
3075
+ wrapper.setVisible(self._visually_expanded())
3076
+ self._menu_entries.append(entry)
3077
+ self._pane.add_menu_widget(wrapper)
3078
+ return line
3079
+
3080
+ def setSelectedNavItem(self, index: int) -> None:
3081
+ """按全局选择索引选中(含折叠项);-1 清空;程序化选中不播放点击动画。"""
3082
+ index = int(index)
3083
+ if index == -1:
3084
+ self._close_submenu(animate=False)
3085
+ self._clear_selection()
3086
+ return
3087
+ entries = self._selectable_entries()
3088
+ if index < 0 or index >= len(entries):
3089
+ raise IndexError(f"导航索引越界: {index}(共 {len(entries)} 项)")
3090
+ entry = entries[index]
3091
+ self._close_submenu(animate=False)
3092
+ self._select_target(top=entry, child=None, settings=False,
3093
+ animate=False, record=False)
3094
+
3095
+ def setNavItem(self, index: int, fonticon=_UNSET, name=_UNSET,
3096
+ layout=_UNSET) -> None:
3097
+ """按普通项内部索引修改;只更新传入的参数,layout=None 表示清空。"""
3098
+ index = int(index)
3099
+ if index < 0 or index >= len(self._nav_items):
3100
+ raise IndexError(f"普通菜单索引越界: {index}")
3101
+ item = self._nav_items[index]
3102
+ if not item.isValid():
3103
+ raise RuntimeError("该 NavItem 已被移除")
3104
+ if fonticon is not _UNSET:
3105
+ item.fonticon = fonticon
3106
+ if name is not _UNSET:
3107
+ item.name = name
3108
+ if layout is not _UNSET:
3109
+ self._set_item_layout(item, layout)
3110
+ self._refresh_item(item)
3111
+
3112
+ def setSettingLayout(self, layout) -> None:
3113
+ if self._settings_item is None:
3114
+ raise RuntimeError("show_settings=False,当前没有设置菜单项")
3115
+ self._set_item_layout(self._settings_item, layout)
3116
+
3117
+ def removeNavItem(self, index: int) -> None:
3118
+ """按普通项内部索引删除,并使对应 NavItem 逻辑失效。"""
3119
+ index = int(index)
3120
+ if index < 0 or index >= len(self._nav_items):
3121
+ raise IndexError(f"普通菜单索引越界: {index}")
3122
+ self._close_submenu(animate=False)
3123
+ item = self._nav_items[index]
3124
+ if self._selected_top is item:
3125
+ self._clear_selection(notify=False)
3126
+ self._unmount_layout(item)
3127
+ self._drop_page(item)
3128
+ if item._wrapper is not None:
3129
+ self._pane.remove_menu_widget(item._wrapper)
3130
+ item._invalidate()
3131
+ self._nav_items.pop(index)
3132
+ if item in self._menu_entries:
3133
+ self._menu_entries.remove(item)
3134
+ self._sanitize_history()
3135
+ self._refresh_indices()
3136
+ self._update_back_enabled()
3137
+ self._update_indicator(animate=False)
3138
+ self._notify_selected_changed()
3139
+
3140
+ def removeNavFoldItem(self, index: int) -> None:
3141
+ """按折叠项内部索引删除(连同子项),相关模型全部逻辑失效。"""
3142
+ index = int(index)
3143
+ if index < 0 or index >= len(self._fold_items):
3144
+ raise IndexError(f"折叠菜单索引越界: {index}")
3145
+ self._close_submenu(animate=False)
3146
+ fold = self._fold_items[index]
3147
+ involved = (self._selected_top is fold or
3148
+ (self._selected_child is not None and
3149
+ self._selected_child._fold is fold))
3150
+ if involved:
3151
+ self._clear_selection(notify=False)
3152
+ for child in list(fold._children):
3153
+ self._unmount_layout(child)
3154
+ self._drop_page(child)
3155
+ self._unmount_layout(fold)
3156
+ self._drop_page(fold)
3157
+ if fold._group is not None:
3158
+ self._pane.remove_menu_widget(fold._group)
3159
+ fold._invalidate()
3160
+ self._fold_items.pop(index)
3161
+ if fold in self._menu_entries:
3162
+ self._menu_entries.remove(fold)
3163
+ self._sanitize_history()
3164
+ self._refresh_indices()
3165
+ self._update_back_enabled()
3166
+ self._update_indicator(animate=False)
3167
+ self._notify_selected_changed()
3168
+
3169
+ def CleatNavItems(self) -> None:
3170
+ """清空普通项、折叠项与分隔符(保留设置项)。"""
3171
+ self._close_submenu(animate=False)
3172
+ self._clear_selection(notify=False)
3173
+ for item in list(self._nav_items):
3174
+ self._unmount_layout(item)
3175
+ self._drop_page(item)
3176
+ if item._wrapper is not None:
3177
+ self._pane.remove_menu_widget(item._wrapper)
3178
+ item._invalidate()
3179
+ for fold in list(self._fold_items):
3180
+ for child in list(fold._children):
3181
+ self._unmount_layout(child)
3182
+ self._drop_page(child)
3183
+ self._unmount_layout(fold)
3184
+ self._drop_page(fold)
3185
+ if fold._group is not None:
3186
+ self._pane.remove_menu_widget(fold._group)
3187
+ fold._invalidate()
3188
+ for entry in list(self._menu_entries):
3189
+ if isinstance(entry, (_SeparatorEntry, _LineEntry)) and entry.widget is not None:
3190
+ self._pane.remove_menu_widget(entry.widget)
3191
+ entry.widget.deleteLater()
3192
+ self._nav_items.clear()
3193
+ self._fold_items.clear()
3194
+ self._menu_entries.clear()
3195
+ self._remembered_folds.clear()
3196
+ self._history.clear()
3197
+ self._update_back_enabled()
3198
+ self._update_indicator(animate=False)
3199
+ self._notify_selected_changed()
3200
+
3201
+ def clearNavItems(self) -> None:
3202
+ self.CleatNavItems()
3203
+
3204
+ # ------------------------------------------------------------------
3205
+ # 公共 API:形态 / 查询
3206
+ # ------------------------------------------------------------------
3207
+ def setPaneState(self, state) -> None:
3208
+ state = _coerce_pane_state(state)
3209
+ if state is self._pane_state:
3210
+ return
3211
+ self._pane_state = state
3212
+ self._reset_open_flags()
3213
+ self._apply_pane_state(manual=False, animate=True, force=True)
3214
+
3215
+ def setExpandedShowInline(self, mode) -> None:
3216
+ mode = _coerce_show_inline(mode)
3217
+ if mode is self._show_inline:
3218
+ return
3219
+ self._show_inline = mode
3220
+ self._reset_open_flags()
3221
+ self._apply_pane_state(manual=False, animate=True, force=True)
3222
+
3223
+ def paneState(self) -> PaneStateEnum:
3224
+ return self._pane_state
3225
+
3226
+ def expandedShowInline(self) -> PaneShowInlineEnum:
3227
+ return self._show_inline
3228
+
3229
+ def effectivePaneState(self) -> PaneStateEnum | None:
3230
+ return self._effective_state
3231
+
3232
+ def usesFlyout(self) -> bool:
3233
+ return self._presentation_flyout
3234
+
3235
+ def indexOf(self, item) -> int:
3236
+ if item is None:
3237
+ return -1
3238
+ fold = getattr(item, "_fold", None)
3239
+ if fold is not None:
3240
+ return fold.globalIndex
3241
+ return self._global_index_of(item)
3242
+
3243
+ def currentSelection(self):
3244
+ """当前选中的 NavItem / NavFoldItem;设置项返回内置设置 NavItem。"""
3245
+ return self._current_target()
3246
+
3247
+ def setHistoryLimit(self, limit: int) -> None:
3248
+ self._history_limit = max(0, int(limit))
3249
+ while len(self._history) > self._history_limit:
3250
+ self._history.pop(0)
3251
+ self._update_back_enabled()
3252
+
3253
+ def historyLimit(self) -> int:
3254
+ return self._history_limit
3255
+
3256
+ # ------------------------------------------------------------------
3257
+ # 选择模型
3258
+ # ------------------------------------------------------------------
3259
+ def _selectable_entries(self) -> list:
3260
+ return [entry for entry in self._menu_entries
3261
+ if isinstance(entry, (NavItem, NavFoldItem))]
3262
+
3263
+ def _global_index_of(self, item) -> int:
3264
+ index = 0
3265
+ for entry in self._menu_entries:
3266
+ if isinstance(entry, (NavItem, NavFoldItem)):
3267
+ if entry is item:
3268
+ return index
3269
+ index += 1
3270
+ return -1
3271
+
3272
+ def _selection_index(self) -> int:
3273
+ if self._settings_selected:
3274
+ return -1
3275
+ if self._selected_child is not None:
3276
+ return self._selected_child._fold.globalIndex
3277
+ if self._selected_top is not None:
3278
+ return self._selected_top.globalIndex
3279
+ return -1
3280
+
3281
+ def _selection_key(self):
3282
+ if self._settings_selected:
3283
+ return ("settings", None)
3284
+ if self._selected_child is not None:
3285
+ return ("child", self._selected_child)
3286
+ if self._selected_top is not None:
3287
+ return ("top", self._selected_top)
3288
+ return None
3289
+
3290
+ def _current_target(self):
3291
+ if self._settings_selected:
3292
+ return self._settings_item
3293
+ if self._selected_child is not None:
3294
+ return self._selected_child
3295
+ return self._selected_top
3296
+
3297
+ def _visually_expanded(self) -> bool:
3298
+ return self._effective_state is PaneStateEnum.Expanded
3299
+
3300
+ def _refresh_indices(self) -> None:
3301
+ for index, item in enumerate(self._nav_items):
3302
+ item._index = index if item.isValid() else -1
3303
+ for index, fold in enumerate(self._fold_items):
3304
+ fold._index = index if fold.isValid() else -1
3305
+ for child_index, child in enumerate(fold._children):
3306
+ child._index = child_index if child.isValid() else -1
3307
+
3308
+ def _sync_selection_models(self) -> None:
3309
+ for item in self._nav_items:
3310
+ if item.isValid():
3311
+ item._set_selected(
3312
+ self._selected_top is item and self._selected_child is None
3313
+ )
3314
+ for fold in self._fold_items:
3315
+ if not fold.isValid():
3316
+ continue
3317
+ branch = (self._selected_top is fold or
3318
+ (self._selected_child is not None and
3319
+ self._selected_child._fold is fold))
3320
+ fold._set_selected(branch)
3321
+ for child in fold._children:
3322
+ if child.isValid():
3323
+ child._set_selected(self._selected_child is child)
3324
+ if (self._selected_child is not None and
3325
+ self._selected_child._fold is fold and
3326
+ self._selected_child in fold._children):
3327
+ child_index = fold._children.index(self._selected_child)
3328
+ else:
3329
+ child_index = -1
3330
+ if branch or fold._last_child_selected is not None:
3331
+ fold._emit_child_selected(child_index)
3332
+ if self._settings_item is not None:
3333
+ self._settings_item._set_selected(self._settings_selected)
3334
+
3335
+ def _notify_selected_changed(self) -> None:
3336
+ key = self._selection_key()
3337
+ value = self._selection_index()
3338
+ if key == self._last_selected_key and value == self._last_selected_index:
3339
+ return
3340
+ self._last_selected_key = key
3341
+ self._last_selected_index = value
3342
+ self.selectedChanged.emit(value)
3343
+
3344
+ def _push_history(self) -> None:
3345
+ snapshot = {
3346
+ "top": self._selected_top,
3347
+ "child": self._selected_child,
3348
+ "settings": self._settings_selected,
3349
+ "folds": {fold: fold._expanded for fold in self._fold_items
3350
+ if fold.isValid()},
3351
+ }
3352
+ self._history.append(snapshot)
3353
+ while len(self._history) > self._history_limit:
3354
+ self._history.pop(0)
3355
+ self._update_back_enabled()
3356
+
3357
+ def _update_back_enabled(self) -> None:
3358
+ if self._back is not None:
3359
+ self._back.setEnabled(bool(self._history))
3360
+
3361
+ def _sanitize_history(self) -> None:
3362
+ """结构性删除后移除失效目标,保留仍然可用的返回步骤。"""
3363
+ cleaned = []
3364
+ for snapshot in self._history:
3365
+ top = snapshot.get("top")
3366
+ child = snapshot.get("child")
3367
+ if top is not None and not getattr(top, "isValid", lambda: False)():
3368
+ continue
3369
+ if child is not None:
3370
+ if not getattr(child, "isValid", lambda: False)():
3371
+ continue
3372
+ if not getattr(child._fold, "isValid", lambda: False)():
3373
+ continue
3374
+ folds = {}
3375
+ for fold, expanded in snapshot.get("folds", {}).items():
3376
+ if getattr(fold, "isValid", lambda: False)():
3377
+ folds[fold] = expanded
3378
+ snapshot["folds"] = folds
3379
+ cleaned.append(snapshot)
3380
+ self._history = cleaned
3381
+ self._update_back_enabled()
3382
+
3383
+ def _select_target(self, *, top, child, settings: bool,
3384
+ animate: bool, record: bool) -> None:
3385
+ new_top = None if settings else top
3386
+ new_child = None if settings else child
3387
+ if (settings == self._settings_selected and
3388
+ new_top is self._selected_top and
3389
+ new_child is self._selected_child):
3390
+ return
3391
+ if record:
3392
+ self._push_history()
3393
+ self._settings_selected = bool(settings)
3394
+ self._selected_top = new_top
3395
+ self._selected_child = new_child
3396
+
3397
+ if (new_child is not None and self._visually_expanded() and
3398
+ not new_child._fold._expanded):
3399
+ self._set_fold_expanded(new_child._fold, True,
3400
+ animate=animate, sync_indicator=False)
3401
+
3402
+ self._sync_selection_models()
3403
+ self._update_indicator(animate=animate)
3404
+ self._show_content_for(self._current_target(), animate=animate)
3405
+ self._notify_selected_changed()
3406
+ self._update_back_enabled()
3407
+
3408
+ def _clear_selection(self, notify: bool = True) -> None:
3409
+ if (self._selected_top is None and self._selected_child is None and
3410
+ not self._settings_selected):
3411
+ return
3412
+ self._selected_top = None
3413
+ self._selected_child = None
3414
+ self._settings_selected = False
3415
+ self._sync_selection_models()
3416
+ self._update_indicator(animate=False)
3417
+ self._show_content_for(None, animate=False)
3418
+ if notify:
3419
+ self._notify_selected_changed()
3420
+
3421
+ def _go_back(self) -> None:
3422
+ if not self._history:
3423
+ return
3424
+ snapshot = self._history.pop()
3425
+ self._close_submenu(animate=False)
3426
+
3427
+ folds = snapshot.get("folds", {})
3428
+ if self._visually_expanded():
3429
+ for fold, expanded in folds.items():
3430
+ if isinstance(fold, NavFoldItem) and fold.isValid():
3431
+ self._set_fold_expanded(fold, bool(expanded),
3432
+ animate=True, sync_indicator=False)
3433
+ else:
3434
+ self._remembered_folds = {
3435
+ fold for fold, expanded in folds.items()
3436
+ if expanded and isinstance(fold, NavFoldItem) and fold.isValid()
3437
+ }
3438
+
3439
+ top = snapshot.get("top")
3440
+ child = snapshot.get("child")
3441
+ settings = bool(snapshot.get("settings"))
3442
+ if top is not None and not getattr(top, "isValid", lambda: False)():
3443
+ top = None
3444
+ if child is not None and not getattr(child, "isValid", lambda: False)():
3445
+ child = None
3446
+ if child is not None:
3447
+ parent = child._fold
3448
+ if not parent.isValid():
3449
+ child = None
3450
+ elif top is not parent:
3451
+ top = parent
3452
+ if settings and self._settings_item is not None:
3453
+ self._selected_top = None
3454
+ self._selected_child = None
3455
+ self._settings_selected = True
3456
+ else:
3457
+ self._settings_selected = False
3458
+ self._selected_top = top
3459
+ self._selected_child = child
3460
+ if (self._selected_child is not None and self._visually_expanded() and
3461
+ not self._selected_child._fold._expanded):
3462
+ self._set_fold_expanded(self._selected_child._fold, True,
3463
+ animate=True, sync_indicator=False)
3464
+
3465
+ self._sync_selection_models()
3466
+ self._update_indicator(animate=True)
3467
+ self._show_content_for(self._current_target(), animate=True)
3468
+ self._notify_selected_changed()
3469
+ self._update_back_enabled()
3470
+
3471
+ # ------------------------------------------------------------------
3472
+ # 交互入口
3473
+ # ------------------------------------------------------------------
3474
+ def _activate_nav_item(self, item: NavItem) -> None:
3475
+ if not item.isValid():
3476
+ return
3477
+ self._close_submenu(animate=False)
3478
+ if item._fold is not None:
3479
+ self._activate_child(item)
3480
+ return
3481
+ self._select_target(top=item, child=None, settings=False,
3482
+ animate=True, record=True)
3483
+
3484
+ def _activate_fold(self, fold: NavFoldItem) -> None:
3485
+ if not fold.isValid():
3486
+ return
3487
+ if not self._visually_expanded():
3488
+ if fold.layout is not None:
3489
+ self._select_target(top=fold, child=None, settings=False,
3490
+ animate=True, record=True)
3491
+ self._toggle_submenu(fold)
3492
+ return
3493
+
3494
+ self._close_submenu(animate=False)
3495
+ self._set_fold_expanded(fold, not fold._expanded, animate=True)
3496
+ if fold.layout is not None:
3497
+ self._select_target(top=fold, child=None, settings=False,
3498
+ animate=True, record=True)
3499
+
3500
+ def _activate_child(self, child: NavItem, from_submenu: bool = False) -> None:
3501
+ if not child.isValid():
3502
+ return
3503
+ self._select_target(top=child._fold, child=child, settings=False,
3504
+ animate=True, record=True)
3505
+ if from_submenu:
3506
+ self._close_submenu(animate=True)
3507
+
3508
+ def _activate_settings(self) -> None:
3509
+ if self._settings_item is None or not self._settings_item.isValid():
3510
+ return
3511
+ self._close_submenu(animate=False)
3512
+ self._select_target(top=None, child=None, settings=True,
3513
+ animate=True, record=True)
3514
+
3515
+ # ------------------------------------------------------------------
3516
+ # 折叠 / 指示器 / 内容
3517
+ # ------------------------------------------------------------------
3518
+ def _set_fold_expanded(self, fold: NavFoldItem, expanded: bool,
3519
+ animate: bool = True,
3520
+ sync_indicator: bool = True) -> None:
3521
+ if not fold.isValid():
3522
+ return
3523
+ expanded = bool(expanded)
3524
+ if expanded and not self._visually_expanded():
3525
+ # 压缩/隐藏时只记住状态,展开分类时再恢复。
3526
+ self._remembered_folds.add(fold)
3527
+ return
3528
+ if (not expanded and not self._visually_expanded() and
3529
+ not fold._expanded):
3530
+ self._remembered_folds.discard(fold)
3531
+ return
3532
+ if fold._expanded == expanded:
3533
+ if fold._group is not None:
3534
+ fold._group.set_expanded(expanded, animate=False)
3535
+ return
3536
+ fold._set_expanded(expanded)
3537
+ if fold._group is not None:
3538
+ fold._group.set_expanded(expanded, animate=animate)
3539
+ if sync_indicator:
3540
+ self._update_indicator(animate=animate)
3541
+
3542
+ def _indicator_target(self):
3543
+ if self._settings_selected:
3544
+ return self._settings_item
3545
+ if self._selected_child is not None:
3546
+ fold = self._selected_child._fold
3547
+ if (self._submenu is not None and fold.layout is None and
3548
+ self._submenu.fold() is fold):
3549
+ return None
3550
+ if self._visually_expanded() and fold._expanded:
3551
+ return self._selected_child
3552
+ return fold
3553
+ return self._selected_top
3554
+
3555
+ def _update_indicator(self, animate: bool = True, retry: bool = True) -> None:
3556
+ target = self._indicator_target()
3557
+ row = target._row if target is not None else None
3558
+ indicator = self._pane.indicator()
3559
+ indicator.set_row(row, animate=animate)
3560
+ for fold in self._fold_items:
3561
+ if fold.isValid() and fold._row is not None:
3562
+ fold._row.set_selected(target is fold)
3563
+ self._update_submenu_selection()
3564
+ # 刚添加到滚动区、尚未完成布局的行会暂时不可见;下一轮事件循环再刷新一次。
3565
+ if retry and row is not None and indicator._row is None:
3566
+ QTimer.singleShot(0, lambda: self._update_indicator(False, retry=False))
3567
+
3568
+ def _update_submenu_selection(self) -> None:
3569
+ if self._submenu is None:
3570
+ return
3571
+ for child in self._submenu.fold().children():
3572
+ row = self._submenu.row_for(child)
3573
+ if row is not None:
3574
+ row.set_selected(self._selected_child is child)
3575
+
3576
+ def _on_layout_shifted(self) -> None:
3577
+ if self._pane.indicator().is_animating():
3578
+ return
3579
+ self._update_indicator(animate=False)
3580
+
3581
+ def _ensure_page(self, item: NavItem | NavFoldItem) -> _ContentPage | None:
3582
+ if item is None or not item.isValid() or item.layout is None:
3583
+ return None
3584
+ page = item._page
3585
+ if page is None:
3586
+ page = _ContentPage()
3587
+ self._content_host.register_page(page)
3588
+ item._page = page
3589
+ key = id(item.layout)
3590
+ entry = self._layout_registry.get(key)
3591
+ if entry is None or entry[0] is not item.layout:
3592
+ return None
3593
+ host = entry[1]
3594
+ self._detach_host_from_pages(host)
3595
+ page.add_host(host)
3596
+ host.show()
3597
+ return page
3598
+
3599
+ def _show_content_for(self, target, animate: bool) -> None:
3600
+ if (target is None or
3601
+ not getattr(target, "isValid", lambda: True)() or
3602
+ getattr(target, "layout", None) is None):
3603
+ if self._content_host.current_page() is not None:
3604
+ self._content_host.show_page(None, False)
3605
+ self._content_revision += 1
3606
+ return
3607
+ page = self._ensure_page(target)
3608
+ if page is None:
3609
+ return
3610
+ changed = self._content_host.current_page() is not page
3611
+ self._content_host.show_page(page, animate if changed else False)
3612
+ if changed:
3613
+ self._content_revision += 1
3614
+ self._schedule_pane_snapshot()
3615
+
3616
+ def _drop_page(self, item) -> None:
3617
+ page = getattr(item, "_page", None)
3618
+ if page is not None:
3619
+ self._content_host.remove_page(page)
3620
+ item._page = None
3621
+
3622
+ def _detach_host_from_pages(self, host: QWidget) -> None:
3623
+ for page in self._content_host.pages():
3624
+ page.remove_host(host)
3625
+
3626
+ # ------------------------------------------------------------------
3627
+ # Layout 所有权
3628
+ # ------------------------------------------------------------------
3629
+ def _make_host(self, layout) -> QWidget:
3630
+ if isinstance(layout, QWidget):
3631
+ return layout
3632
+ if not isinstance(layout, QLayout):
3633
+ raise TypeError("layout 必须是 QLayout 或 QWidget")
3634
+ parent_widget = layout.parentWidget()
3635
+ if parent_widget is not None and parent_widget.layout() is layout:
3636
+ return parent_widget
3637
+ if layout.parent() is not None:
3638
+ raise ValueError("layout 不能是另一个布局的子布局")
3639
+ host = _LayoutHost()
3640
+ host.setAutoFillBackground(False)
3641
+ host.setLayout(layout)
3642
+ return host
3643
+
3644
+ def _host_for_layout(self, layout) -> QWidget:
3645
+ key = id(layout)
3646
+ entry = self._layout_registry.get(key)
3647
+ if entry is not None and entry[0] is layout:
3648
+ return entry[1]
3649
+ host = self._make_host(layout)
3650
+ self._layout_registry[key] = (layout, host)
3651
+ return host
3652
+
3653
+ def _park_host(self, host: QWidget) -> None:
3654
+ self._detach_host_from_pages(host)
3655
+ host.hide()
3656
+ host.setParent(self._layout_pool)
3657
+
3658
+ def _unmount_layout(self, item) -> None:
3659
+ layout = getattr(item, "_layout", None)
3660
+ if layout is None:
3661
+ return
3662
+ key = id(layout)
3663
+ item._layout = None
3664
+ if self._layout_owner.get(key) is item:
3665
+ self._layout_owner.pop(key, None)
3666
+ entry = self._layout_registry.get(key)
3667
+ if entry is not None:
3668
+ self._park_host(entry[1])
3669
+ if self._current_target() is item:
3670
+ self._show_content_for(None, animate=False)
3671
+
3672
+ def _set_item_layout(self, item, layout) -> None:
3673
+ if not item.isValid():
3674
+ raise RuntimeError("目标菜单项已被移除")
3675
+ if layout is item._layout:
3676
+ return
3677
+ if item._layout is not None:
3678
+ self._unmount_layout(item)
3679
+
3680
+ if layout is None:
3681
+ if self._current_target() is item:
3682
+ self._show_content_for(None, animate=False)
3683
+ self._content_revision += 1
3684
+ self._schedule_pane_snapshot()
3685
+ return
3686
+
3687
+ existing = self._layout_owner.get(id(layout))
3688
+ if existing is not None and existing is not item:
3689
+ warnings.warn(
3690
+ f"layout 已挂载在 {existing!r},后挂载的 {item!r} 生效,"
3691
+ "前一个菜单项已取消挂载",
3692
+ RuntimeWarning, stacklevel=3,
3693
+ )
3694
+ if getattr(existing, "_layout", None) is layout:
3695
+ existing._layout = None
3696
+ self._layout_owner.pop(id(layout), None)
3697
+ entry = self._layout_registry.get(id(layout))
3698
+ if entry is not None:
3699
+ self._park_host(entry[1])
3700
+ if self._current_target() is existing:
3701
+ self._show_content_for(None, animate=False)
3702
+
3703
+ host = self._host_for_layout(layout)
3704
+ self._layout_owner[id(layout)] = item
3705
+ item._layout = layout
3706
+ page = self._ensure_page(item)
3707
+ if page is not None:
3708
+ self._detach_host_from_pages(host)
3709
+ page.add_host(host)
3710
+ host.show()
3711
+ if self._current_target() is item:
3712
+ self._show_content_for(item, animate=False)
3713
+ self._content_revision += 1
3714
+ self._schedule_pane_snapshot()
3715
+
3716
+ def _schedule_pane_snapshot(self) -> None:
3717
+ if (self._effective_state is PaneStateEnum.Expanded and
3718
+ self._presentation_flyout):
3719
+ self._pane_snapshot_timer.start(ANIM_MS + 30)
3720
+
3721
+ def _refresh_item(self, item) -> None:
3722
+ row = getattr(item, "_row", None)
3723
+ if row is not None:
3724
+ row.refresh_content()
3725
+ if self._submenu is not None and isinstance(item, NavItem):
3726
+ flyout_row = self._submenu.row_for(item)
3727
+ if flyout_row is not None:
3728
+ flyout_row.refresh_content()
3729
+
3730
+ # ------------------------------------------------------------------
3731
+ # 形态计算
3732
+ # ------------------------------------------------------------------
3733
+ def _base_state(self) -> PaneStateEnum:
3734
+ state = self._pane_state
3735
+ width = max(1, self.width())
3736
+ if state is PaneStateEnum.Auto:
3737
+ if width > self._compact_threshold:
3738
+ return PaneStateEnum.Expanded
3739
+ if width < self._hide_threshold:
3740
+ return PaneStateEnum.Hide
3741
+ return PaneStateEnum.Compact
3742
+ if state is PaneStateEnum.Expanded:
3743
+ return PaneStateEnum.Expanded
3744
+ if state in (PaneStateEnum.Compact, PaneStateEnum.ForceCompact):
3745
+ return PaneStateEnum.Compact
3746
+ return PaneStateEnum.Hide
3747
+
3748
+ def _is_flyout_presentation(self) -> bool:
3749
+ mode = self._show_inline
3750
+ if mode is PaneShowInlineEnum.Inline:
3751
+ return False
3752
+ if mode is PaneShowInlineEnum.Flyout:
3753
+ return True
3754
+ if self._pane_state in (PaneStateEnum.Compact, PaneStateEnum.Expanded):
3755
+ return False
3756
+ if self._pane_state in (PaneStateEnum.ForceCompact, PaneStateEnum.Hide):
3757
+ return True
3758
+ return self.width() <= self._compact_threshold
3759
+
3760
+ def _reset_open_flags(self) -> None:
3761
+ base = self._base_state()
3762
+ flyout = self._is_flyout_presentation()
3763
+ self._last_base = base
3764
+ if base is PaneStateEnum.Expanded:
3765
+ self._pane_flyout_open = flyout
3766
+ self._pane_open_inline = not flyout
3767
+ else:
3768
+ self._pane_flyout_open = False
3769
+ self._pane_open_inline = False
3770
+
3771
+ def _compute_effective(self) -> tuple[PaneStateEnum, bool]:
3772
+ base = self._base_state()
3773
+ flyout = self._is_flyout_presentation()
3774
+ if base is PaneStateEnum.Expanded:
3775
+ opened = self._pane_flyout_open if flyout else self._pane_open_inline
3776
+ return (PaneStateEnum.Expanded if opened else PaneStateEnum.Compact), flyout
3777
+ if base is PaneStateEnum.Compact:
3778
+ opened = self._pane_flyout_open if flyout else self._pane_open_inline
3779
+ return (PaneStateEnum.Expanded if opened else PaneStateEnum.Compact), flyout
3780
+ opened = self._pane_flyout_open if flyout else self._pane_open_inline
3781
+ return (PaneStateEnum.Expanded if opened else PaneStateEnum.Hide), flyout
3782
+
3783
+ def _target_geometry(self) -> dict:
3784
+ base = self._base_state()
3785
+ rail = 0 if base is PaneStateEnum.Hide else COMPACT_WIDTH
3786
+ height = float(max(1, self.height()))
3787
+ effective = self._effective_state or base
3788
+ if effective is PaneStateEnum.Expanded:
3789
+ if self._presentation_flyout:
3790
+ return {
3791
+ "pane_width": float(EXPANDED_WIDTH), "pane_x": 4.0,
3792
+ "pane_y": 4.0, "pane_height": max(0.0, height - 8),
3793
+ "content_x": float(rail), "flyout": True,
3794
+ }
3795
+ return {
3796
+ "pane_width": float(EXPANDED_WIDTH), "pane_x": 0.0,
3797
+ "pane_y": 0.0, "pane_height": height,
3798
+ "content_x": float(EXPANDED_WIDTH), "flyout": False,
3799
+ }
3800
+ if effective is PaneStateEnum.Compact:
3801
+ return {
3802
+ "pane_width": float(rail), "pane_x": 0.0,
3803
+ "pane_y": 0.0, "pane_height": height,
3804
+ "content_x": float(rail), "flyout": False,
3805
+ }
3806
+ return {
3807
+ "pane_width": 0.0, "pane_x": 0.0, "pane_y": 0.0,
3808
+ "pane_height": height, "content_x": 0.0, "flyout": False,
3809
+ }
3810
+
3811
+ def _apply_pane_state(self, manual: bool, animate: bool = True,
3812
+ force: bool = False) -> None:
3813
+ base = self._base_state()
3814
+ presentation = self._is_flyout_presentation()
3815
+ effective, _ = self._compute_effective()
3816
+ old_effective = self._effective_state
3817
+ old_presentation = self._presentation_flyout
3818
+ was_flyout_visible = (old_effective is PaneStateEnum.Expanded and
3819
+ old_presentation)
3820
+
3821
+ new_tuple = (effective, presentation)
3822
+ if (not force and old_effective is not None and
3823
+ new_tuple == self._last_state_tuple):
3824
+ self._sync_geometry()
3825
+ return
3826
+
3827
+ if (effective is not PaneStateEnum.Compact and
3828
+ self._submenu is not None):
3829
+ self._close_submenu(animate=False)
3830
+
3831
+ self._effective_state = effective
3832
+ self._presentation_flyout = presentation
3833
+ self._last_base = base
3834
+ self._root.set_base_width(0 if base is PaneStateEnum.Hide else COMPACT_WIDTH)
3835
+
3836
+ expanded = effective is PaneStateEnum.Expanded
3837
+ flyout_visual = ((expanded and presentation) or
3838
+ (not expanded and was_flyout_visible and animate))
3839
+ self._pane.set_flyout(flyout_visual)
3840
+ self._update_header(expanded)
3841
+ if expanded and presentation:
3842
+ self._capture_pane_snapshot_if_needed()
3843
+ self._apply_visual_expansion(expanded, animate=animate)
3844
+ self._pane.set_compact(not expanded)
3845
+
3846
+ target = self._target_geometry()
3847
+ duration = ANIM_MS if animate and self.isVisible() else 0
3848
+
3849
+ def finish() -> None:
3850
+ if not expanded or not presentation:
3851
+ self._pane.set_flyout(False)
3852
+ self._root.set_flyout_mode(False)
3853
+ if expanded and presentation:
3854
+ self._capture_pane_snapshot_if_needed()
3855
+
3856
+ self._root.animate_to(
3857
+ pane_width=target["pane_width"],
3858
+ pane_x=target["pane_x"],
3859
+ pane_y=target["pane_y"],
3860
+ pane_height=target["pane_height"],
3861
+ content_x=target["content_x"],
3862
+ duration=duration,
3863
+ flyout=flyout_visual,
3864
+ on_finished=finish,
3865
+ )
3866
+
3867
+ if old_effective is not None and new_tuple != self._last_state_tuple:
3868
+ self.paneStateChanged.emit(manual, old_effective, effective,
3869
+ presentation)
3870
+ self._last_state_tuple = new_tuple
3871
+ self._sync_app_filter()
3872
+
3873
+ def _sync_geometry(self) -> None:
3874
+ if self._effective_state is None:
3875
+ return
3876
+ target = self._target_geometry()
3877
+ self._root.animate_to(
3878
+ pane_width=target["pane_width"],
3879
+ pane_x=target["pane_x"],
3880
+ pane_y=target["pane_y"],
3881
+ pane_height=target["pane_height"],
3882
+ content_x=target["content_x"],
3883
+ duration=0,
3884
+ flyout=target["flyout"],
3885
+ )
3886
+ if target["flyout"]:
3887
+ self._pane_snapshot_timer.start(0)
3888
+ self._pane.set_flyout(True)
3889
+ else:
3890
+ self._pane.set_flyout(False)
3891
+ self._update_header(self._visually_expanded())
3892
+
3893
+ def _apply_visual_expansion(self, expanded: bool, animate: bool) -> None:
3894
+ previous = self._was_visually_expanded
3895
+ if expanded:
3896
+ if previous is not True:
3897
+ restore = set(self._remembered_folds)
3898
+ if self._selected_child is not None:
3899
+ restore.add(self._selected_child._fold)
3900
+ for fold in list(restore):
3901
+ if (isinstance(fold, NavFoldItem) and fold.isValid() and
3902
+ not fold._expanded):
3903
+ self._set_fold_expanded(fold, True, animate=animate,
3904
+ sync_indicator=False)
3905
+ self._set_rows_compact(False)
3906
+ for separator in self._separators():
3907
+ separator.set_compact(False, animate=animate)
3908
+ for line in self._lines():
3909
+ line.setVisible(True)
3910
+ else:
3911
+ if previous is True:
3912
+ self._remembered_folds = {
3913
+ fold for fold in self._fold_items
3914
+ if fold.isValid() and fold._expanded
3915
+ }
3916
+ for fold in list(self._fold_items):
3917
+ if fold.isValid() and fold._expanded:
3918
+ self._set_fold_expanded(fold, False, animate=animate,
3919
+ sync_indicator=False)
3920
+ self._set_rows_compact(True)
3921
+ for separator in self._separators():
3922
+ separator.set_compact(True, animate=animate)
3923
+ for line in self._lines():
3924
+ line.setVisible(False)
3925
+ self._was_visually_expanded = expanded
3926
+ self._update_indicator(animate=animate)
3927
+
3928
+ def _set_rows_compact(self, compact: bool) -> None:
3929
+ for item in self._nav_items:
3930
+ if item.isValid() and item._row is not None:
3931
+ item._row.set_compact(compact)
3932
+ item._row.refresh_content()
3933
+ for fold in self._fold_items:
3934
+ if fold.isValid() and fold._row is not None:
3935
+ fold._row.set_compact(compact)
3936
+ fold._row.refresh_content()
3937
+ for child in fold._children:
3938
+ if child.isValid() and child._row is not None:
3939
+ child._row.set_compact(compact)
3940
+ child._row.refresh_content()
3941
+ if self._settings_row is not None:
3942
+ self._settings_row.set_compact(compact)
3943
+ self._settings_row.refresh_content()
3944
+
3945
+ def _separators(self) -> list[_SeparatorWidget]:
3946
+ return [entry.widget for entry in self._menu_entries
3947
+ if isinstance(entry, _SeparatorEntry) and entry.widget is not None]
3948
+
3949
+ def _lines(self) -> list[QWidget]:
3950
+ return [entry.widget for entry in self._menu_entries
3951
+ if isinstance(entry, _LineEntry) and entry.widget is not None]
3952
+
3953
+ def _update_header(self, expanded: bool) -> None:
3954
+ if self._header is None:
3955
+ return
3956
+ vertical = (not expanded and
3957
+ self._effective_state is PaneStateEnum.Compact)
3958
+ self._header.set_vertical(vertical)
3959
+ self._header.setVisible(self._header.width() > 0)
3960
+ if self._header.width() > 0:
3961
+ self._pane.set_header_height(self._header.height())
3962
+ clearance = 0
3963
+ if (self._effective_state is PaneStateEnum.Hide and
3964
+ self._header.width() > 0):
3965
+ clearance = self._header.height() + 4
3966
+ if clearance != self._last_clearance:
3967
+ self._last_clearance = clearance
3968
+ self._content_revision += 1
3969
+ self._content_card.set_top_clearance(clearance)
3970
+
3971
+ # ------------------------------------------------------------------
3972
+ # 汉堡 / Flyout / 事件
3973
+ # ------------------------------------------------------------------
3974
+ def _toggle_pane(self) -> None:
3975
+ self._close_submenu(animate=False)
3976
+ presentation = self._is_flyout_presentation()
3977
+ if self._effective_state is PaneStateEnum.Expanded:
3978
+ if presentation:
3979
+ self._pane_flyout_open = False
3980
+ else:
3981
+ self._pane_open_inline = False
3982
+ else:
3983
+ if presentation:
3984
+ self._pane_flyout_open = True
3985
+ self._pane_open_inline = False
3986
+ else:
3987
+ self._pane_open_inline = True
3988
+ self._pane_flyout_open = False
3989
+ self._apply_pane_state(manual=True, animate=True, force=True)
3990
+
3991
+ def _toggle_submenu(self, fold: NavFoldItem) -> None:
3992
+ if self._submenu is not None and self._submenu.fold() is fold:
3993
+ self._close_submenu(animate=True)
3994
+ return
3995
+ if not fold.children():
3996
+ return
3997
+ self._open_submenu(fold)
3998
+
3999
+ def _open_submenu(self, fold: NavFoldItem) -> None:
4000
+ self._close_submenu(animate=False)
4001
+ flyout = _SubmenuFlyout(self, fold)
4002
+ self._submenu = flyout
4003
+ flyout.show_flyout()
4004
+ # 某些平台上 Tool 窗口首次 show 后需要再抬一次,避免被主窗口盖住。
4005
+ QTimer.singleShot(0, self._raise_submenu)
4006
+ self._update_indicator(animate=False)
4007
+ self._sync_app_filter()
4008
+
4009
+ def _raise_submenu(self) -> None:
4010
+ if self._submenu is not None and self._submenu.isVisible():
4011
+ self._submenu.raise_()
4012
+
4013
+ def _close_submenu(self, animate: bool = True) -> None:
4014
+ flyout = self._submenu
4015
+ if flyout is None:
4016
+ return
4017
+ flyout.close_flyout(animate=animate)
4018
+ if not animate:
4019
+ self._submenu = None
4020
+ self._update_indicator(animate=False)
4021
+ self._sync_app_filter()
4022
+
4023
+ def _on_submenu_closed(self, flyout: _SubmenuFlyout) -> None:
4024
+ if self._submenu is flyout:
4025
+ self._submenu = None
4026
+ flyout.deleteLater()
4027
+ self._update_indicator(animate=False)
4028
+ self._sync_app_filter()
4029
+
4030
+ def _widget_in_pane(self, widget: QWidget | None) -> bool:
4031
+ if widget is None:
4032
+ return False
4033
+ return widget is self._pane or self._pane.isAncestorOf(widget)
4034
+
4035
+ def _widget_in_header(self, widget: QWidget | None) -> bool:
4036
+ if widget is None or self._header is None:
4037
+ return False
4038
+ return widget is self._header or self._header.isAncestorOf(widget)
4039
+
4040
+ def _handle_outside_press(self, widget: QWidget | None) -> None:
4041
+ if self._submenu is not None and self._submenu.isVisible():
4042
+ if not self._submenu.contains_widget(widget):
4043
+ trigger_row = self._submenu.fold()._row
4044
+ on_trigger = (widget is trigger_row or
4045
+ (trigger_row is not None and
4046
+ trigger_row.isAncestorOf(widget)))
4047
+ if not on_trigger:
4048
+ self._close_submenu(animate=True)
4049
+ return
4050
+ if (self._pane_flyout_open and self._presentation_flyout and
4051
+ self._effective_state is PaneStateEnum.Expanded):
4052
+ if not (self._widget_in_pane(widget) or
4053
+ self._widget_in_header(widget)):
4054
+ self._pane_flyout_open = False
4055
+ self._apply_pane_state(manual=True, animate=True, force=True)
4056
+
4057
+ def eventFilter(self, obj, event) -> bool:
4058
+ if not self.isVisible():
4059
+ return False
4060
+ event_type = event.type()
4061
+ if event_type == QEvent.Type.MouseButtonPress:
4062
+ try:
4063
+ position = event.globalPosition().toPoint()
4064
+ except AttributeError:
4065
+ position = None
4066
+ if position is not None:
4067
+ self._handle_outside_press(QApplication.widgetAt(position))
4068
+ return False
4069
+ if event_type == QEvent.Type.KeyPress and event.key() == Qt.Key_Escape:
4070
+ if self._submenu is not None:
4071
+ self._close_submenu(animate=True)
4072
+ return True
4073
+ if (self._pane_flyout_open and self._presentation_flyout and
4074
+ self._effective_state is PaneStateEnum.Expanded):
4075
+ self._pane_flyout_open = False
4076
+ self._apply_pane_state(manual=True, animate=True, force=True)
4077
+ return True
4078
+ return False
4079
+
4080
+ def _install_app_filter(self) -> None:
4081
+ app = QApplication.instance()
4082
+ if app is not None and not self._app_filter_installed:
4083
+ app.installEventFilter(self)
4084
+ self._app_filter_installed = True
4085
+
4086
+ def _remove_app_filter(self) -> None:
4087
+ app = QApplication.instance()
4088
+ if app is not None and self._app_filter_installed:
4089
+ app.removeEventFilter(self)
4090
+ self._app_filter_installed = False
4091
+
4092
+ def _sync_app_filter(self) -> None:
4093
+ """只在有 Flyout 打开时监听全局点击,避免无谓的全局事件过滤。"""
4094
+ needed = (self.isVisible() and
4095
+ (self._submenu is not None or
4096
+ (self._pane_flyout_open and self._presentation_flyout and
4097
+ self._effective_state is PaneStateEnum.Expanded)))
4098
+ if needed:
4099
+ self._install_app_filter()
4100
+ else:
4101
+ self._remove_app_filter()
4102
+
4103
+ # ------------------------------------------------------------------
4104
+ # 快照
4105
+ # ------------------------------------------------------------------
4106
+ def _blurred_grab(self, widget: QWidget, cache_attr: str,
4107
+ style: FrostStyle = FLYOUT_GLASS):
4108
+ origin = widget.mapToGlobal(QPoint(0, 0))
4109
+ dpr_hint = float(widget.devicePixelRatioF() or 1.0)
4110
+ key = (widget.width(), widget.height(), origin.x(), origin.y(),
4111
+ dpr_hint, self._content_revision,
4112
+ style.blur, style.brightness, style.saturation)
4113
+ cached = getattr(self, cache_attr, None)
4114
+ if cached is not None and cached[0] == key:
4115
+ return cached[1]
4116
+ if widget.width() <= 0 or widget.height() <= 0:
4117
+ return None
4118
+ pixmap = widget.grab()
4119
+ if pixmap.isNull():
4120
+ return None
4121
+ image = pixmap.toImage()
4122
+ dpr = float(image.devicePixelRatio() or dpr_hint)
4123
+ blurred = blur_qimage(
4124
+ image,
4125
+ style.blur * dpr,
4126
+ style.brightness,
4127
+ style.saturation,
4128
+ )
4129
+ blurred.setDevicePixelRatio(dpr)
4130
+ result = (blurred, origin)
4131
+ setattr(self, cache_attr, (key, result))
4132
+ return result
4133
+
4134
+ def _grab_window_snapshot(self, style: FrostStyle = FLYOUT_GLASS):
4135
+ return self._blurred_grab(self, "_window_snapshot_cache", style)
4136
+
4137
+ def _capture_pane_snapshot_if_needed(self) -> None:
4138
+ if not self.isVisible():
4139
+ return
4140
+ if not (self._effective_state is PaneStateEnum.Expanded and
4141
+ self._presentation_flyout):
4142
+ return
4143
+ # 抓窗口时临时隐藏浮层,避免把 Flyout 自身画进快照;
4144
+ # 同时让快照覆盖侧边栏所在的窄条,消除玻璃接缝。
4145
+ hidden = []
4146
+ for widget in (self._pane, self._root.shadow(), self._header):
4147
+ if widget is not None and widget.isVisible():
4148
+ hidden.append(widget)
4149
+ widget.setVisible(False)
4150
+ try:
4151
+ snapshot = self._blurred_grab(self, "_pane_snapshot_cache")
4152
+ finally:
4153
+ for widget in hidden:
4154
+ widget.setVisible(True)
4155
+ self._last_window_pos = self.mapToGlobal(QPoint(0, 0))
4156
+ if snapshot is None:
4157
+ self._pane.set_snapshot(None, QPoint(0, 0))
4158
+ else:
4159
+ self._pane.set_snapshot(snapshot[0], snapshot[1])
4160
+
4161
+ # ------------------------------------------------------------------
4162
+ # 窗口事件
4163
+ # ------------------------------------------------------------------
4164
+ def showEvent(self, event) -> None:
4165
+ super().showEvent(event)
4166
+ if self._ready:
4167
+ self._last_window_pos = self.mapToGlobal(QPoint(0, 0))
4168
+ self._content_revision += 1
4169
+ self._sync_geometry()
4170
+ self._update_indicator(animate=False)
4171
+ QTimer.singleShot(0, lambda: self._update_indicator(animate=False))
4172
+ self._sync_app_filter()
4173
+
4174
+ def resizeEvent(self, event) -> None:
4175
+ super().resizeEvent(event)
4176
+ if not self._ready:
4177
+ return
4178
+ self._content_revision += 1
4179
+ if self._submenu is not None:
4180
+ self._close_submenu(animate=False)
4181
+ if self._pane_state is PaneStateEnum.Auto:
4182
+ base = self._base_state()
4183
+ if base is not self._last_base:
4184
+ self._reset_open_flags()
4185
+ self._apply_pane_state(manual=False, animate=True, force=True)
4186
+ return
4187
+ self._sync_geometry()
4188
+
4189
+ def moveEvent(self, event) -> None:
4190
+ super().moveEvent(event)
4191
+ if not self._ready:
4192
+ return
4193
+ position = self.mapToGlobal(QPoint(0, 0))
4194
+ delta = position - self._last_window_pos
4195
+ self._last_window_pos = position
4196
+ if delta.x() or delta.y():
4197
+ self._pane.shift_snapshot(delta.x(), delta.y())
4198
+ if self._submenu is not None:
4199
+ self._submenu.move_with_window(delta.x(), delta.y())
4200
+
4201
+ def _handle_activation_change(self) -> None:
4202
+ if self._submenu is None:
4203
+ return
4204
+ if self.isActiveWindow():
4205
+ return
4206
+ flyout = self._submenu
4207
+ active = QApplication.activeWindow()
4208
+ if (flyout.isActiveWindow() or active is flyout or
4209
+ (active is not None and flyout.isAncestorOf(active))):
4210
+ return
4211
+ self._close_submenu(animate=False)
4212
+
4213
+ def changeEvent(self, event) -> None:
4214
+ if event.type() in (QEvent.Type.WindowDeactivate,
4215
+ QEvent.Type.ActivationChange):
4216
+ self._handle_activation_change()
4217
+ super().changeEvent(event)
4218
+
4219
+ def event(self, event):
4220
+ if event.type() == QEvent.Type.WindowDeactivate:
4221
+ self._handle_activation_change()
4222
+ return super().event(event)
4223
+
4224
+ def hideEvent(self, event) -> None:
4225
+ if self._submenu is not None:
4226
+ self._close_submenu(animate=False)
4227
+ self._remove_app_filter()
4228
+ super().hideEvent(event)
4229
+
4230
+ def closeEvent(self, event) -> None:
4231
+ self._close_submenu(animate=False)
4232
+ self._pane_snapshot_timer.stop()
4233
+ self._remove_app_filter()
4234
+ self._root.stop_animation()
4235
+ super().closeEvent(event)
4236
+
4237
+
4238
+ __all__ = [
4239
+ "NavigationView",
4240
+ "NavItem",
4241
+ "NavFoldItem",
4242
+ "PaneStateEnum",
4243
+ "PaneShowInlineEnum",
4244
+ "HighLightPanel",
4245
+ "FontIcon",
4246
+ "DemoWindow",
4247
+ "DEMO_QSS",
4248
+ ]
4249
+
4250
+
4251
+ # ==== DEMO ====
4252
+
4253
+
4254
+ #: Demo 用的 QSS:数值与 THEME_DEFAULTS / 当前常量保持一致,可在这里改主题。
4255
+ DEMO_QSS = """
4256
+ NavigationView {
4257
+ qproperty-selectedBg: #2d2d2d;
4258
+ qproperty-selectedHoverBg: #292929;
4259
+ qproperty-selectedPressedBg: #2d2d2d;
4260
+ qproperty-hoverBg: #2d2d2d;
4261
+ qproperty-pressedBg: #292929;
4262
+ qproperty-textColor: #ffffff;
4263
+ qproperty-pressedText: #cfcfcf;
4264
+ qproperty-separatorColor: #cccccc;
4265
+ qproperty-dividerColor: #313131;
4266
+ qproperty-indicatorColor: #99ebff;
4267
+ qproperty-indicatorWidth: 2;
4268
+ qproperty-indicatorHeight: 16;
4269
+ qproperty-navItemRadius: 4;
4270
+ qproperty-flyoutRadius: 8;
4271
+ qproperty-flyoutItemRadius: 4;
4272
+ }
4273
+
4274
+ HighLightPanel {
4275
+ qproperty-cornerRadius: 8;
4276
+ }
4277
+
4278
+ _CapsuleScrollBar {
4279
+ qproperty-barColor: #9f9f9f;
4280
+ }
4281
+ """
4282
+
4283
+
4284
+ def _page(title: str, text: str) -> QVBoxLayout:
4285
+ """Demo 用的简单页面布局。"""
4286
+ box = QVBoxLayout()
4287
+ head = QLabel(title)
4288
+ head.setStyleSheet("font-size: 22px; font-weight: 600; color: #ffffff;")
4289
+ body = QLabel(text)
4290
+ body.setWordWrap(True)
4291
+ body.setStyleSheet("color: #bdbdbd; font-size: 14px;")
4292
+ box.addWidget(head)
4293
+ box.addWidget(body)
4294
+ box.addStretch(1)
4295
+ return box
4296
+
4297
+
4298
+ class DemoWindow(NavigationView):
4299
+ """最短用法示例。"""
4300
+
4301
+ def __init__(self):
4302
+ super().__init__()
4303
+ self.setWindowTitle("NavigationView Demo")
4304
+ self.resize(1180, 760)
4305
+
4306
+ self.addNavItem("\uE80F", "主页", _page("主页", "欢迎使用 NavigationView。"))
4307
+ self.addNavItem("\uE8A5", "文档", _page("文档", "这里是文档内容。"))
4308
+ self.addSeparator("资源")
4309
+
4310
+ dev = self.addNavFoldItem("\uE713", "开发")
4311
+ dev.addNavItem("\uE774", "网络", _page("网络", "网络相关设置。"))
4312
+ dev.addNavItem("\uE703", "蓝牙", _page("蓝牙", "蓝牙相关设置。"))
4313
+
4314
+ help = self.addNavFoldItem("\uE897", "帮助", _page("帮助", "帮助与反馈。"))
4315
+ help.addNavItem("\uE939", "反馈", _page("反馈", "反馈与建议。"))
4316
+ help.addNavItem("\uE946", "关于", _page("关于", "NavigationView 示例。"))
4317
+ self.setSettingLayout(_page("设置", "应用设置。"))
4318
+ self.setSelectedNavItem(0)
4319
+
4320
+
4321
+ def main() -> int:
4322
+ app = QApplication(sys.argv)
4323
+ app.setApplicationName("NavigationView")
4324
+ app.setStyleSheet(DEMO_QSS)
4325
+ window = DemoWindow()
4326
+ window.show()
4327
+ return app.exec()
4328
+
4329
+
4330
+ if __name__ == "__main__":
4331
+ raise SystemExit(main())
4332
+
4333
+
4334
+