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/QUI.py +4 -0
- bubble/__init__.py +8 -0
- bubble/cards.py +1503 -0
- bubble/dev_navview.py +4334 -0
- bubble/dev_titlebar.py +2900 -0
- bubble/glasskit/__init__.py +94 -0
- bubble/glasskit/config.py +77 -0
- bubble/glasskit/energy.py +202 -0
- bubble/glasskit/glass.py +1034 -0
- bubble/glasskit/gpu.py +43 -0
- bubble/glasskit/imaging.py +823 -0
- bubble/glasskit/settings.py +82 -0
- bubble/i18n.py +27 -0
- bubble/icon.py +261 -0
- bubble/levels/__init__.py +32 -0
- bubble/levels/component.py +106 -0
- bubble/levels/manager.py +334 -0
- bubble/styles.py +282 -0
- bubble/uiWindow.py +262 -0
- bubble/widgets.py +962 -0
- bubbleui-0.4.4a1.dist-info/METADATA +18 -0
- bubbleui-0.4.4a1.dist-info/RECORD +25 -0
- bubbleui-0.4.4a1.dist-info/WHEEL +5 -0
- bubbleui-0.4.4a1.dist-info/licenses/LICENSE +21 -0
- bubbleui-0.4.4a1.dist-info/top_level.txt +1 -0
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
|
+
|