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/cards.py
ADDED
|
@@ -0,0 +1,1503 @@
|
|
|
1
|
+
from .QUI import *
|
|
2
|
+
from .icon import FontIcon, IconImage
|
|
3
|
+
from .levels import level_component
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from uiml import set_style
|
|
8
|
+
|
|
9
|
+
__all__ = ['Card', 'ContentCard', 'ContentCardState', 'PressableRow', 'Expander', 'create_expander_row']
|
|
10
|
+
|
|
11
|
+
def _has_visual_content(label) -> bool:
|
|
12
|
+
"""判断图标 / 文字标签是否有内容:有文字或有图片都算。"""
|
|
13
|
+
if label is None:
|
|
14
|
+
return False
|
|
15
|
+
pixmap = label.pixmap() if isinstance(label, QLabel) else None
|
|
16
|
+
if pixmap is not None and not pixmap.isNull():
|
|
17
|
+
return True
|
|
18
|
+
return not _is_empty(label.text())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ----------------------------- 通用小工具 -----------------------------
|
|
22
|
+
def _normalize_text(text) -> str:
|
|
23
|
+
return "" if text is None else str(text)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_empty(text) -> bool:
|
|
27
|
+
"""None、空字符串(含仅空白)都算无内容。"""
|
|
28
|
+
return text is None or not str(text).strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _prep_text_label(label: QLabel):
|
|
32
|
+
label.setWordWrap(True)
|
|
33
|
+
label.setMinimumWidth(0)
|
|
34
|
+
label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _hfw_policy(horizontal, vertical) -> QSizePolicy:
|
|
38
|
+
"""带 heightForWidth 的尺寸策略。
|
|
39
|
+
|
|
40
|
+
自动换行的文字,其高度取决于宽度;只有打开 heightForWidth,
|
|
41
|
+
父布局才能拿到正确高度,否则会按「一行」的最小高度把文字压扁。
|
|
42
|
+
"""
|
|
43
|
+
policy = QSizePolicy(horizontal, vertical)
|
|
44
|
+
policy.setHeightForWidth(True)
|
|
45
|
+
return policy
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _HFWContainer(QWidget):
|
|
49
|
+
"""把内部布局的 heightForWidth / sizeHint 透传给父布局的容器。
|
|
50
|
+
|
|
51
|
+
普通 QWidget 不会把布局的 heightForWidth 暴露出去,父布局于是会
|
|
52
|
+
算出错误高度(要么大得离谱,要么把换行文字压成一行)。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def heightForWidth(self, width: int) -> int:
|
|
56
|
+
lay = self.layout()
|
|
57
|
+
if lay is not None and lay.hasHeightForWidth():
|
|
58
|
+
return lay.heightForWidth(width)
|
|
59
|
+
return super().heightForWidth(width)
|
|
60
|
+
|
|
61
|
+
def sizeHint(self) -> QSize:
|
|
62
|
+
lay = self.layout()
|
|
63
|
+
if lay is not None and lay.hasHeightForWidth() and self.width() > 0:
|
|
64
|
+
return QSize(max(1, lay.sizeHint().width()), max(1, lay.heightForWidth(self.width())))
|
|
65
|
+
return super().sizeHint()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _as_icon_label(title) -> Optional[QLabel]:
|
|
69
|
+
"""把 title 参数(QLabel / QPixmap / QIcon / 图标字符 / None)统一成 QLabel。
|
|
70
|
+
|
|
71
|
+
- QLabel:原样使用(例如内嵌图片的 iconImg)
|
|
72
|
+
- QPixmap / QIcon:包成内嵌图片图标 IconImage
|
|
73
|
+
- 其它:按图标字符生成 fonticon
|
|
74
|
+
"""
|
|
75
|
+
if title is None or isinstance(title, QLabel):
|
|
76
|
+
return title
|
|
77
|
+
if isinstance(title, (QPixmap, QIcon)):
|
|
78
|
+
return IconImage(image=title)
|
|
79
|
+
return str(title)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TextLabel(QLabel):
|
|
83
|
+
"""带 text_color / text_size 属性的文字标签。
|
|
84
|
+
|
|
85
|
+
颜色与字号都写进控件自身样式表,优先级高于全局样式表里的
|
|
86
|
+
`* { color: ...; font-size: ... }`(这类全局规则会覆盖 setFont() 的效果);
|
|
87
|
+
并可由 qss 里的 `qproperty-text_color` / `qproperty-text_size` 覆盖(见 Gui/res/styles/dark.qss)。
|
|
88
|
+
|
|
89
|
+
示例:
|
|
90
|
+
title = TextLabel("副标题", color="#FFFFFF", size=14)
|
|
91
|
+
desc = TextLabel("描述", color="#D1D1D1", size=12)
|
|
92
|
+
desc.set_text_color("#FFB020") # 也可由 qss 用 qproperty-text_color 覆盖
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, text: str = "", color: str = "#FFFFFF", size: int = 14, parent=None):
|
|
96
|
+
super().__init__(_normalize_text(text), parent)
|
|
97
|
+
self._text_color = QColor(color)
|
|
98
|
+
self._text_size = int(size)
|
|
99
|
+
self._apply_style()
|
|
100
|
+
|
|
101
|
+
def _apply_style(self):
|
|
102
|
+
f = self.font()
|
|
103
|
+
f.setPixelSize(self._text_size)
|
|
104
|
+
self.setFont(f)
|
|
105
|
+
# 不在这里写 background:背景交给样式表按层级(level)决定
|
|
106
|
+
self.setStyleSheet(
|
|
107
|
+
f"color: {self._text_color.name()}; "
|
|
108
|
+
f"font-size: {self._text_size}px;"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def get_text_color(self) -> QColor:
|
|
112
|
+
return self._text_color
|
|
113
|
+
|
|
114
|
+
def set_text_color(self, color):
|
|
115
|
+
color = QColor(color)
|
|
116
|
+
if color != self._text_color:
|
|
117
|
+
self._text_color = color
|
|
118
|
+
self._apply_style()
|
|
119
|
+
|
|
120
|
+
def get_text_size(self) -> int:
|
|
121
|
+
return self._text_size
|
|
122
|
+
|
|
123
|
+
def set_text_size(self, size: int):
|
|
124
|
+
size = int(size)
|
|
125
|
+
if size != self._text_size:
|
|
126
|
+
self._text_size = size
|
|
127
|
+
self._apply_style()
|
|
128
|
+
|
|
129
|
+
text_color = Property(QColor, get_text_color, set_text_color)
|
|
130
|
+
text_size = Property(int, get_text_size, set_text_size)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@level_component(contribution=1)
|
|
134
|
+
class Card(QWidget):
|
|
135
|
+
"""基础卡片:只负责背景、圆角与边框,并挂载一个外部布局。
|
|
136
|
+
|
|
137
|
+
组件层级:Card 通过 ``@level_component(contribution=1)`` 为所有子控件
|
|
138
|
+
增加 1 级;Card 自己的层级不包含这个 +1(例如挂在主窗口下时 raw=0)。
|
|
139
|
+
ContentCard / Expander / PressableRow 都继承 Card,因此自动具备同样
|
|
140
|
+
的层级贡献,无需重复声明。
|
|
141
|
+
|
|
142
|
+
参数:
|
|
143
|
+
layout —— 要挂载到卡片上的布局(None 则只是一块背景板)。
|
|
144
|
+
布局的边距 / 间距完全由调用方决定,Card 不做干预。
|
|
145
|
+
|
|
146
|
+
配色:底色 / 边框色的默认值见 _BG、_BORDER,可由 qss 里的
|
|
147
|
+
`.Card { qproperty-bg_color: ...; qproperty-border_color: ...; }` 覆盖
|
|
148
|
+
(临时配色表见 Gui/res/styles/dark.qss);圆角、边框宽度等形状属性仍由代码控制。
|
|
149
|
+
|
|
150
|
+
示例:
|
|
151
|
+
layout = QVBoxLayout()
|
|
152
|
+
layout.setContentsMargins(16, 16, 16, 16)
|
|
153
|
+
layout.addWidget(TextLabel("卡片内容"))
|
|
154
|
+
card = Card(layout=layout)
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
STYLE_CLASS = "Card"
|
|
158
|
+
|
|
159
|
+
_BG = "#323232" # 默认底色(qss 未加载时的兜底值)
|
|
160
|
+
_BORDER = "#232323" # 默认边框色
|
|
161
|
+
_RADIUS = 4 # 圆角(形状,不由 qss 控制)
|
|
162
|
+
_BORDER_WIDTH = 1 # 边框宽度(形状,不由 qss 控制)
|
|
163
|
+
|
|
164
|
+
def __init__(self, layout=None, parent=None):
|
|
165
|
+
super().__init__(parent)
|
|
166
|
+
|
|
167
|
+
# ---- 形状参数 ----
|
|
168
|
+
self._radius = self._RADIUS
|
|
169
|
+
self._border_width = self._BORDER_WIDTH
|
|
170
|
+
|
|
171
|
+
# ---- 配色(默认值,可被 qss 覆盖)----
|
|
172
|
+
self._bg_color = QColor(self._BG)
|
|
173
|
+
self._border_color = QColor(self._BORDER)
|
|
174
|
+
|
|
175
|
+
self.setSizePolicy(_hfw_policy(QSizePolicy.Expanding, QSizePolicy.Preferred))
|
|
176
|
+
|
|
177
|
+
if layout is not None:
|
|
178
|
+
self.mount_layout(layout)
|
|
179
|
+
|
|
180
|
+
set_style(self, self.STYLE_CLASS)
|
|
181
|
+
|
|
182
|
+
# --------------------------- 高度随宽度变化(换行文字) ---------------------------
|
|
183
|
+
def heightForWidth(self, width: int) -> int:
|
|
184
|
+
"""返回布局在给定宽度下所需的高度(供父布局使用)。"""
|
|
185
|
+
lay = self.layout()
|
|
186
|
+
if lay is not None and lay.hasHeightForWidth():
|
|
187
|
+
return lay.heightForWidth(width)
|
|
188
|
+
return super().heightForWidth(width)
|
|
189
|
+
|
|
190
|
+
def sizeHint(self) -> QSize:
|
|
191
|
+
"""宽度已知时按换行后的高度给建议尺寸(否则 Qt 会算出巨大且错误的高度)。"""
|
|
192
|
+
lay = self.layout()
|
|
193
|
+
if lay is not None and lay.hasHeightForWidth():
|
|
194
|
+
width = self.width()
|
|
195
|
+
if width > 0:
|
|
196
|
+
return QSize(max(1, lay.sizeHint().width()), max(1, lay.heightForWidth(width)))
|
|
197
|
+
return super().sizeHint()
|
|
198
|
+
|
|
199
|
+
def _sync_content_height(self):
|
|
200
|
+
"""把自身最小高度锁到「当前宽度下换行后的自然高度」。
|
|
201
|
+
|
|
202
|
+
换行文字的高度取决于宽度,而父布局只会在尺寸变化时重新询问一次,
|
|
203
|
+
因此这里在每次尺寸变化后主动把最小高度对齐,避免被父布局压扁
|
|
204
|
+
(典型场景:Expander 展开时把多行描述压成一行)。
|
|
205
|
+
"""
|
|
206
|
+
lay = self.layout()
|
|
207
|
+
if lay is None or not lay.hasHeightForWidth():
|
|
208
|
+
return
|
|
209
|
+
width = self.width()
|
|
210
|
+
if width <= 0:
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
lay.invalidate()
|
|
214
|
+
natural = lay.heightForWidth(width)
|
|
215
|
+
if natural > 0 and self.minimumHeight() != natural:
|
|
216
|
+
self.setMinimumHeight(natural)
|
|
217
|
+
self.updateGeometry()
|
|
218
|
+
|
|
219
|
+
def resizeEvent(self, event):
|
|
220
|
+
super().resizeEvent(event)
|
|
221
|
+
self._sync_content_height()
|
|
222
|
+
|
|
223
|
+
def showEvent(self, event):
|
|
224
|
+
super().showEvent(event)
|
|
225
|
+
self._sync_content_height()
|
|
226
|
+
|
|
227
|
+
# --------------------------- 配色 ---------------------------
|
|
228
|
+
def get_bg_color(self) -> QColor:
|
|
229
|
+
return self._bg_color
|
|
230
|
+
|
|
231
|
+
def set_bg_color(self, color):
|
|
232
|
+
color = QColor(color)
|
|
233
|
+
if color != self._bg_color:
|
|
234
|
+
self._bg_color = color
|
|
235
|
+
self.update()
|
|
236
|
+
|
|
237
|
+
def get_border_color(self) -> QColor:
|
|
238
|
+
return self._border_color
|
|
239
|
+
|
|
240
|
+
def set_border_color(self, color):
|
|
241
|
+
color = QColor(color)
|
|
242
|
+
if color != self._border_color:
|
|
243
|
+
self._border_color = color
|
|
244
|
+
self.update()
|
|
245
|
+
|
|
246
|
+
bg_color = Property(QColor, get_bg_color, set_bg_color)
|
|
247
|
+
border_color = Property(QColor, get_border_color, set_border_color)
|
|
248
|
+
|
|
249
|
+
# --------------------------- 布局挂载 ---------------------------
|
|
250
|
+
def mount_layout(self, layout):
|
|
251
|
+
"""把外部布局挂载为卡片布局(会替换已有布局)。"""
|
|
252
|
+
old = self.layout()
|
|
253
|
+
if old is not None and old is not layout:
|
|
254
|
+
while old.count():
|
|
255
|
+
item = old.takeAt(0)
|
|
256
|
+
w = item.widget()
|
|
257
|
+
if w is not None:
|
|
258
|
+
w.setParent(None)
|
|
259
|
+
# 交给临时 widget 释放旧布局,否则 setLayout 会拒绝安装新布局
|
|
260
|
+
QWidget().setLayout(old)
|
|
261
|
+
self.setLayout(layout)
|
|
262
|
+
|
|
263
|
+
# --------------------------- 自绘 ---------------------------
|
|
264
|
+
def paintEvent(self, event):
|
|
265
|
+
painter = QPainter(self)
|
|
266
|
+
painter.setRenderHint(QPainter.Antialiasing, True)
|
|
267
|
+
|
|
268
|
+
rect = QRectF(self.rect())
|
|
269
|
+
|
|
270
|
+
path = QPainterPath()
|
|
271
|
+
path.addRoundedRect(rect, self._radius, self._radius)
|
|
272
|
+
painter.fillPath(path, self._bg_color)
|
|
273
|
+
|
|
274
|
+
# QPen 宽度为 0 时是 cosmetic pen(仍会画 1px),无边框必须直接跳过描边
|
|
275
|
+
if self._border_width > 0:
|
|
276
|
+
pen = QPen(self._border_color)
|
|
277
|
+
pen.setWidthF(self._border_width)
|
|
278
|
+
painter.setPen(pen)
|
|
279
|
+
painter.setBrush(Qt.NoBrush)
|
|
280
|
+
inner = rect.adjusted(0.5, 0.5, -0.5, -0.5)
|
|
281
|
+
painter.drawRoundedRect(inner, self._radius, self._radius)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class ContentCardState(IntEnum):
|
|
285
|
+
"""ContentCard 的显示模式。
|
|
286
|
+
|
|
287
|
+
Wide / Warp / NoIcon 是卡片实际可能处于的显示模式;Auto 只用于
|
|
288
|
+
setCardMode() 恢复按宽度自适应,不会出现在 cardModeChanged 信号里。
|
|
289
|
+
cardStateChanged 只会广播 Wide / Warp;cardModeChanged 广播 Wide / Warp / NoIcon。
|
|
290
|
+
|
|
291
|
+
示例:
|
|
292
|
+
card.setCardMode(ContentCardState.Warp) # 强制窄模式
|
|
293
|
+
card.setCardMode(ContentCardState.Auto) # 恢复自动
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
Wide = 0
|
|
297
|
+
Warp = 1
|
|
298
|
+
NoIcon = 2
|
|
299
|
+
Auto = 3
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ContentCard(Card):
|
|
303
|
+
"""可自定义的内容卡片(宽 / 窄模式自适应),布局由 Card 基类挂载。
|
|
304
|
+
|
|
305
|
+
参数:
|
|
306
|
+
title —— 图标 QLabel(用 fonticon() 生成);None 表示无图标
|
|
307
|
+
subtitle —— 子标题文字
|
|
308
|
+
description —— 描述文字
|
|
309
|
+
|
|
310
|
+
宽模式:
|
|
311
|
+
[图标] ←20px→ [副标题] ←32px→ [右侧 layout]
|
|
312
|
+
[描述(自动换行)]
|
|
313
|
+
|
|
314
|
+
窄模式:
|
|
315
|
+
[图标] ←20px→ [副标题]
|
|
316
|
+
↓ 2px
|
|
317
|
+
[描述]
|
|
318
|
+
↓ 8px
|
|
319
|
+
[右侧 layout(左对齐)]
|
|
320
|
+
|
|
321
|
+
阈值参数:
|
|
322
|
+
wrap_threshold —— 组件宽度 < 该值时切到窄模式(默认 475)
|
|
323
|
+
no_icon_threshold —— 组件宽度 < 该值时隐藏图标(默认 100)
|
|
324
|
+
|
|
325
|
+
空内容处理(None / 空字符串 / 纯空白 都视为「无内容」,对应布局项隐藏):
|
|
326
|
+
title 为空 —— 按无图标模式处理,中间列不再保留 20px 缩进
|
|
327
|
+
subtitle 为空 —— 隐藏副标题,剩下的描述居中
|
|
328
|
+
description 为空 —— 隐藏描述,剩下的副标题居中
|
|
329
|
+
subtitle 与 description 都为空 —— 两个布局项都隐藏,中间列收敛为 0 宽(不再进入窄模式)
|
|
330
|
+
|
|
331
|
+
显示模式(ContentCardState,忽略当前宽度强制切换):
|
|
332
|
+
Wide —— 强制宽模式(图标 + 文字并排、右侧 layout 贴右)
|
|
333
|
+
Warp —— 强制窄模式(右侧 layout 换到描述下方)
|
|
334
|
+
NoIcon —— 强制隐藏图标(宽 / 窄保持当前设置),等同 hideIcon(True)
|
|
335
|
+
Auto —— 恢复按宽度自适应;只用于 setCardMode,不会出现在 cardModeChanged 里
|
|
336
|
+
Wide / Warp / Auto 只切换宽 / 窄,不改变图标显隐(图标由 hideIcon / NoIcon 单独控制)。
|
|
337
|
+
|
|
338
|
+
信号:
|
|
339
|
+
cardModeChanged(旧模式, 新模式) —— 显示模式变化(Wide / Warp / NoIcon),
|
|
340
|
+
参数都是 ContentCardState
|
|
341
|
+
cardStateChanged(新模式) —— 只有宽 / 窄布局变化时发出(只有 Wide / Warp):
|
|
342
|
+
手动切 Wide / Warp 总会广播一次;
|
|
343
|
+
Auto 结果与当前状态不同才广播 Wide / Warp
|
|
344
|
+
(宽度小到自动隐藏图标时按 Warp 算);
|
|
345
|
+
尺寸变化导致宽 / 窄切换时广播;
|
|
346
|
+
NoIcon / ShowIcon 只改图标,不广播。
|
|
347
|
+
IconHided(bool) —— 图标显隐变化,bool 为更新后是否隐藏
|
|
348
|
+
hideIcon(bool):True 强制隐藏,False 恢复自动(Auto 模式下宽度低于
|
|
349
|
+
no_icon_threshold 时仍会隐藏)。
|
|
350
|
+
hideIcon(bool) 单独控制图标:True 强制隐藏;False 恢复自动
|
|
351
|
+
(Auto 模式下宽度低于 no_icon_threshold 时仍会隐藏)。
|
|
352
|
+
|
|
353
|
+
示例:
|
|
354
|
+
right = QHBoxLayout()
|
|
355
|
+
right.addWidget(fonticon("\uE76C", size=14))
|
|
356
|
+
card = ContentCard(
|
|
357
|
+
title=fonticon("\uE799", size=20),
|
|
358
|
+
subtitle="Wi-Fi",
|
|
359
|
+
description="已连接:Office-5G",
|
|
360
|
+
right_layout=right,
|
|
361
|
+
)
|
|
362
|
+
card.set_subtitle("蓝牙") # 文字可随时修改
|
|
363
|
+
card.setCardMode(ContentCardState.Warp) # 强制窄模式
|
|
364
|
+
card.setCardMode(ContentCardState.Auto) # 恢复按宽度自适应
|
|
365
|
+
card.hideIcon(True) # 单独隐藏图标
|
|
366
|
+
card.hideIcon(False) # 恢复显示(不影响宽 / 窄)
|
|
367
|
+
card.cardModeChanged.connect(lambda old, new: print(old, new))
|
|
368
|
+
card.cardStateChanged.connect(lambda state: print(state))
|
|
369
|
+
card.IconHided.connect(lambda hidden: print(hidden))
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
cardModeChanged = Signal(ContentCardState, ContentCardState)
|
|
373
|
+
cardStateChanged = Signal(ContentCardState)
|
|
374
|
+
IconHided = Signal(bool)
|
|
375
|
+
|
|
376
|
+
_ICON_GAP = 20 # 图标 ↔ 中间列
|
|
377
|
+
_CONTENT_GAP = 32 # 中间列 ↔ 右侧 layout(宽模式)
|
|
378
|
+
_NARROW_TOP_GAP = 8 # 右侧 layout 顶部额外间距(窄模式)
|
|
379
|
+
_ROW_GAP = 2 # 副标题 ↔ 描述
|
|
380
|
+
|
|
381
|
+
_SUBTITLE_COLOR = "#FFFFFF" # 默认配色,可由 qss 的 .CardSubtitle 覆盖
|
|
382
|
+
_DESC_COLOR = "#D1D1D1" # 默认配色,可由 qss 的 .CardDescription 覆盖
|
|
383
|
+
_SUBTITLE_SIZE = 14 # 字号(写进标签自身样式表,不受全局 `*` 影响)
|
|
384
|
+
_DESC_SIZE = 12
|
|
385
|
+
|
|
386
|
+
def __init__(self, title="\uE799", subtitle="副标题文字",
|
|
387
|
+
description="描述说明文字", right_layout=None,
|
|
388
|
+
wrap_threshold=475, no_icon_threshold=100, parent=None):
|
|
389
|
+
self._wrap_threshold = wrap_threshold
|
|
390
|
+
self._no_icon_threshold = no_icon_threshold
|
|
391
|
+
|
|
392
|
+
# ---- 状态 ----
|
|
393
|
+
self._width_mode = ContentCardState.Auto # 宽度模式:Auto / Wide / Warp
|
|
394
|
+
self._hide_icon = False # hideIcon() 的强制隐藏标记
|
|
395
|
+
self._card_mode = ContentCardState.Wide # 当前显示模式(供 cardModeChanged)
|
|
396
|
+
self._card_state = ContentCardState.Wide # 已上报的宽 / 窄布局状态(供 cardStateChanged)
|
|
397
|
+
self._icon_hidden = False # 已上报的图标隐藏状态(供 IconHided)
|
|
398
|
+
self._is_narrow = False
|
|
399
|
+
self._icon_visible = True
|
|
400
|
+
self._mid_empty = False
|
|
401
|
+
self._suspend = False
|
|
402
|
+
self._has_title = False
|
|
403
|
+
self._has_subtitle = False
|
|
404
|
+
self._has_description = False
|
|
405
|
+
|
|
406
|
+
subtitle = _normalize_text(subtitle)
|
|
407
|
+
description = _normalize_text(description)
|
|
408
|
+
|
|
409
|
+
# ================= 根布局(交给 Card 基类挂载) =================
|
|
410
|
+
self._root = QHBoxLayout()
|
|
411
|
+
self._root.setContentsMargins(16, 16, 16, 16)
|
|
412
|
+
# spacing = 0,水平间距全部由 mid_layout 的 left margin
|
|
413
|
+
# 和右侧 layout 的 left margin 精确控制
|
|
414
|
+
self._root.setSpacing(0)
|
|
415
|
+
|
|
416
|
+
super().__init__(layout=self._root, parent=parent)
|
|
417
|
+
|
|
418
|
+
# 垂直方向按内容高度收敛(仍保留 heightForWidth,换行文字才能算对高度)
|
|
419
|
+
self.setSizePolicy(_hfw_policy(QSizePolicy.Expanding, QSizePolicy.Fixed))
|
|
420
|
+
|
|
421
|
+
# ================= 第一列:图标(外部传入的 QLabel,None 则整列省略) =================
|
|
422
|
+
self._title_label = _as_icon_label(title)
|
|
423
|
+
if self._title_label is not None:
|
|
424
|
+
self._root.addWidget(self._title_label, 0, Qt.AlignVCenter)
|
|
425
|
+
|
|
426
|
+
# ================= 第二列:副标题 + 描述 =================
|
|
427
|
+
# 中间列放在容器里,副标题与描述都为空时该列收敛为 0 宽(不占位),
|
|
428
|
+
# 但仍保留拉伸因子,保证右侧 layout 依旧贴右。
|
|
429
|
+
self._mid_container = _HFWContainer(self)
|
|
430
|
+
self._mid_container.setSizePolicy(
|
|
431
|
+
_hfw_policy(QSizePolicy.Expanding, QSizePolicy.Preferred))
|
|
432
|
+
self._mid_container.setMinimumWidth(0)
|
|
433
|
+
|
|
434
|
+
self._mid_layout = QVBoxLayout(self._mid_container)
|
|
435
|
+
# left = 24 → 图标 ↔ 中间列内容 24px
|
|
436
|
+
self._mid_layout.setContentsMargins(self._ICON_GAP, 0, 0, 0)
|
|
437
|
+
self._mid_layout.setSpacing(self._ROW_GAP)
|
|
438
|
+
|
|
439
|
+
self._subtitle_label = TextLabel(subtitle, self._SUBTITLE_COLOR, self._SUBTITLE_SIZE)
|
|
440
|
+
_prep_text_label(self._subtitle_label)
|
|
441
|
+
set_style(self._subtitle_label, "CardSubtitle")
|
|
442
|
+
|
|
443
|
+
self._desc_label = TextLabel(description, self._DESC_COLOR, self._DESC_SIZE)
|
|
444
|
+
_prep_text_label(self._desc_label)
|
|
445
|
+
set_style(self._desc_label, "CardDescription")
|
|
446
|
+
|
|
447
|
+
self._mid_layout.addWidget(self._subtitle_label, 1, Qt.AlignVCenter)
|
|
448
|
+
self._mid_layout.addWidget(self._desc_label, 1, Qt.AlignVCenter)
|
|
449
|
+
|
|
450
|
+
self._root.addWidget(self._mid_container, 1)
|
|
451
|
+
|
|
452
|
+
# ================= 第三列:右侧 layout 容器 =================
|
|
453
|
+
self._right_container = QWidget(self)
|
|
454
|
+
self._right_container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
|
455
|
+
self._right_container.setMinimumWidth(0)
|
|
456
|
+
self._root.addWidget(self._right_container, 0, Qt.AlignVCenter)
|
|
457
|
+
|
|
458
|
+
if right_layout is not None:
|
|
459
|
+
self.set_right_layout(right_layout)
|
|
460
|
+
|
|
461
|
+
self._sync_content_state()
|
|
462
|
+
|
|
463
|
+
# --------------------------- 帮助函数 ---------------------------
|
|
464
|
+
def _sync_content_state(self):
|
|
465
|
+
"""按 title / subtitle / description 是否有内容(文字或图片)同步可见性。"""
|
|
466
|
+
self._has_title = _has_visual_content(self._title_label)
|
|
467
|
+
self._has_subtitle = _has_visual_content(self._subtitle_label)
|
|
468
|
+
self._has_description = _has_visual_content(self._desc_label)
|
|
469
|
+
|
|
470
|
+
if self._title_label is not None:
|
|
471
|
+
self._title_label.setVisible(self._has_title)
|
|
472
|
+
self._subtitle_label.setVisible(self._has_subtitle)
|
|
473
|
+
self._desc_label.setVisible(self._has_description)
|
|
474
|
+
|
|
475
|
+
# 副标题与描述都没有 → 中间列无内容(宽度收敛为 0,不占位)
|
|
476
|
+
mid_empty = not (self._has_subtitle or self._has_description)
|
|
477
|
+
if mid_empty != self._mid_empty:
|
|
478
|
+
self._mid_empty = mid_empty
|
|
479
|
+
self._apply_right_margins_for_mode()
|
|
480
|
+
self._update_layout_mode()
|
|
481
|
+
self.updateGeometry()
|
|
482
|
+
self._sync_content_height()
|
|
483
|
+
|
|
484
|
+
# --------------------------- 文字自定义 ---------------------------
|
|
485
|
+
def set_title(self, title: Optional[Union[QLabel, str]]):
|
|
486
|
+
"""设置图标;传入 QLabel(fonticon() 生成)或在位替换旧的图标。"""
|
|
487
|
+
label = _as_icon_label(title)
|
|
488
|
+
old = self._title_label
|
|
489
|
+
if label is not old:
|
|
490
|
+
index = self._root.indexOf(old) if old is not None else 0
|
|
491
|
+
if old is not None:
|
|
492
|
+
self._root.removeWidget(old)
|
|
493
|
+
old.setParent(None)
|
|
494
|
+
self._title_label = label
|
|
495
|
+
if label is not None:
|
|
496
|
+
self._root.insertWidget(index, label, 0, Qt.AlignVCenter)
|
|
497
|
+
self._sync_content_state()
|
|
498
|
+
self._update_layout_mode()
|
|
499
|
+
|
|
500
|
+
def title(self) -> Optional[QLabel]:
|
|
501
|
+
return self._title_label
|
|
502
|
+
|
|
503
|
+
def set_subtitle(self, text: Optional[str]):
|
|
504
|
+
self._subtitle_label.setText(_normalize_text(text))
|
|
505
|
+
self._sync_content_state()
|
|
506
|
+
self._update_layout_mode()
|
|
507
|
+
|
|
508
|
+
def subtitle(self) -> str:
|
|
509
|
+
return self._subtitle_label.text()
|
|
510
|
+
|
|
511
|
+
def set_description(self, text: Optional[str]):
|
|
512
|
+
self._desc_label.setText(_normalize_text(text))
|
|
513
|
+
self._sync_content_state()
|
|
514
|
+
self._update_layout_mode()
|
|
515
|
+
|
|
516
|
+
def description(self) -> str:
|
|
517
|
+
return self._desc_label.text()
|
|
518
|
+
|
|
519
|
+
# --------------------------- 右侧 layout 自定义 ---------------------------
|
|
520
|
+
def set_right_layout(self, layout):
|
|
521
|
+
old = self._right_container.layout()
|
|
522
|
+
if old is not None:
|
|
523
|
+
while old.count():
|
|
524
|
+
item = old.takeAt(0)
|
|
525
|
+
w = item.widget()
|
|
526
|
+
if w is not None:
|
|
527
|
+
w.setParent(None)
|
|
528
|
+
QWidget().setLayout(old)
|
|
529
|
+
|
|
530
|
+
self._right_container.setLayout(layout)
|
|
531
|
+
# Card 内部统一管理间距,不保留用户 layout 的外部边距
|
|
532
|
+
self._apply_right_margins_for_mode()
|
|
533
|
+
|
|
534
|
+
if self.isVisible():
|
|
535
|
+
self._update_layout_mode()
|
|
536
|
+
|
|
537
|
+
def right_container(self) -> QWidget:
|
|
538
|
+
return self._right_container
|
|
539
|
+
|
|
540
|
+
# --------------------------- 间距随模式调整 ---------------------------
|
|
541
|
+
def _apply_right_margins_for_mode(self):
|
|
542
|
+
"""Card 内部统一为右侧 layout 设定边距:
|
|
543
|
+
- 宽模式:left = 32 → 与描述保持 32px
|
|
544
|
+
- 窄模式:left = 0、top = 8 → 与描述左对齐,且行间 4 + 8 = 12px
|
|
545
|
+
- 中间列隐藏:left = 0 → 不保留与空中间列的间隔
|
|
546
|
+
"""
|
|
547
|
+
lay = self._right_container.layout()
|
|
548
|
+
if lay is None:
|
|
549
|
+
return
|
|
550
|
+
if self._is_narrow:
|
|
551
|
+
lay.setContentsMargins(0, self._NARROW_TOP_GAP, 0, 0)
|
|
552
|
+
elif self._mid_empty:
|
|
553
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
554
|
+
else:
|
|
555
|
+
lay.setContentsMargins(self._CONTENT_GAP, 0, 0, 0)
|
|
556
|
+
|
|
557
|
+
# --------------------------- 模式切换 ---------------------------
|
|
558
|
+
def _switch_mode(self, narrow: bool):
|
|
559
|
+
if self._suspend or narrow == self._is_narrow:
|
|
560
|
+
return
|
|
561
|
+
self._suspend = True
|
|
562
|
+
try:
|
|
563
|
+
if narrow:
|
|
564
|
+
self._apply_narrow()
|
|
565
|
+
else:
|
|
566
|
+
self._apply_wide()
|
|
567
|
+
finally:
|
|
568
|
+
self._suspend = False
|
|
569
|
+
|
|
570
|
+
def _apply_wide(self):
|
|
571
|
+
self._mid_layout.removeWidget(self._right_container)
|
|
572
|
+
self._right_container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
|
573
|
+
self._root.addWidget(self._right_container, 0, Qt.AlignVCenter)
|
|
574
|
+
self._is_narrow = False
|
|
575
|
+
self._apply_right_margins_for_mode()
|
|
576
|
+
self._right_container.show()
|
|
577
|
+
self.updateGeometry()
|
|
578
|
+
|
|
579
|
+
def _apply_narrow(self):
|
|
580
|
+
self._root.removeWidget(self._right_container)
|
|
581
|
+
self._right_container.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
|
582
|
+
self._mid_layout.addWidget(self._right_container, 0, Qt.AlignLeft)
|
|
583
|
+
self._is_narrow = True
|
|
584
|
+
self._apply_right_margins_for_mode()
|
|
585
|
+
self._right_container.show()
|
|
586
|
+
self.updateGeometry()
|
|
587
|
+
|
|
588
|
+
def _set_card_mode(self, mode: ContentCardState):
|
|
589
|
+
"""更新当前显示模式并发出 cardModeChanged(Auto 不会作为状态出现)。"""
|
|
590
|
+
if mode == self._card_mode:
|
|
591
|
+
return
|
|
592
|
+
old = self._card_mode
|
|
593
|
+
self._card_mode = mode
|
|
594
|
+
self.cardModeChanged.emit(old, mode)
|
|
595
|
+
|
|
596
|
+
def _set_card_state(self, state: ContentCardState):
|
|
597
|
+
"""更新宽 / 窄布局状态并发出 cardStateChanged(只有 Wide / Warp)。"""
|
|
598
|
+
if state == self._card_state:
|
|
599
|
+
return
|
|
600
|
+
self._card_state = state
|
|
601
|
+
self.cardStateChanged.emit(state)
|
|
602
|
+
|
|
603
|
+
def _set_icon_hidden(self, hidden: bool):
|
|
604
|
+
"""更新图标显隐状态并发出 IconHided(bool 为更新后是否隐藏)。"""
|
|
605
|
+
hidden = bool(hidden)
|
|
606
|
+
if hidden == self._icon_hidden:
|
|
607
|
+
return
|
|
608
|
+
self._icon_hidden = hidden
|
|
609
|
+
self.IconHided.emit(hidden)
|
|
610
|
+
|
|
611
|
+
def _update_layout_mode(self):
|
|
612
|
+
"""按模式 / 宽度更新图标与宽窄布局,并同步 cardModeChanged。"""
|
|
613
|
+
if self._suspend:
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
width = self.width()
|
|
617
|
+
auto = self._width_mode == ContentCardState.Auto
|
|
618
|
+
|
|
619
|
+
self._suspend = True
|
|
620
|
+
try:
|
|
621
|
+
# --- 图标可见性 ---
|
|
622
|
+
# 强制模式忽略宽度阈值;Auto 才按 no_icon_threshold 自动隐藏
|
|
623
|
+
if self._hide_icon:
|
|
624
|
+
show_icon = False
|
|
625
|
+
elif auto and width > 0:
|
|
626
|
+
show_icon = self._has_title and width >= self._no_icon_threshold
|
|
627
|
+
else:
|
|
628
|
+
show_icon = self._has_title
|
|
629
|
+
|
|
630
|
+
if self._title_label is not None and show_icon != self._icon_visible:
|
|
631
|
+
self._title_label.setVisible(show_icon)
|
|
632
|
+
self._icon_visible = show_icon
|
|
633
|
+
# 图标隐藏(或被隐藏的中间列)时不再需要 24px 左缩进
|
|
634
|
+
left = self._ICON_GAP if (show_icon and not self._mid_empty) else 0
|
|
635
|
+
m = self._mid_layout.contentsMargins()
|
|
636
|
+
if m.left() != left:
|
|
637
|
+
self._mid_layout.setContentsMargins(left, m.top(), m.right(), m.bottom())
|
|
638
|
+
|
|
639
|
+
# --- 宽 / 窄 ---
|
|
640
|
+
if auto:
|
|
641
|
+
if width <= 0:
|
|
642
|
+
narrow = self._is_narrow # 尺寸未知时保持现状
|
|
643
|
+
elif self._mid_empty:
|
|
644
|
+
narrow = False # 中间列没有文字时无需换行
|
|
645
|
+
else:
|
|
646
|
+
narrow = width < self._wrap_threshold
|
|
647
|
+
else:
|
|
648
|
+
narrow = self._width_mode == ContentCardState.Warp
|
|
649
|
+
finally:
|
|
650
|
+
self._suspend = False
|
|
651
|
+
|
|
652
|
+
self._switch_mode(narrow)
|
|
653
|
+
|
|
654
|
+
# --- 当前布局状态 / 图标状态 / 组合显示模式 ---
|
|
655
|
+
state = ContentCardState.Warp if self._is_narrow else ContentCardState.Wide
|
|
656
|
+
icon_hidden = bool(self._has_title and not show_icon)
|
|
657
|
+
# 图标隐藏时 cardModeChanged 上报 NoIcon;Auto 永远不会作为状态出现
|
|
658
|
+
mode = ContentCardState.NoIcon if icon_hidden else state
|
|
659
|
+
|
|
660
|
+
self._set_icon_hidden(icon_hidden)
|
|
661
|
+
self._set_card_state(state)
|
|
662
|
+
self._set_card_mode(mode)
|
|
663
|
+
|
|
664
|
+
# --------------------------- 显示模式 ---------------------------
|
|
665
|
+
def setCardMode(self, mode: ContentCardState):
|
|
666
|
+
"""强制切换显示模式(不论当前宽度)。
|
|
667
|
+
|
|
668
|
+
mode 取 ContentCardState:
|
|
669
|
+
Wide —— 强制宽模式,并广播 cardStateChanged(Wide)
|
|
670
|
+
Warp —— 强制窄模式,并广播 cardStateChanged(Warp)
|
|
671
|
+
NoIcon —— 强制隐藏图标,宽 / 窄保持当前设置(等同 hideIcon(True));
|
|
672
|
+
只改图标,不广播 cardStateChanged
|
|
673
|
+
Auto —— 恢复按宽度自动切换;解析结果与当前状态不同才广播
|
|
674
|
+
Wide / Warp(不会广播 Auto)
|
|
675
|
+
Wide / Warp / Auto 只切换宽 / 窄,不改变图标显隐;要重新显示图标请调 hideIcon(False)。
|
|
676
|
+
手动切 Wide / Warp 时总会广播一次 cardStateChanged(即使状态没变)。
|
|
677
|
+
"""
|
|
678
|
+
mode = ContentCardState(mode)
|
|
679
|
+
if mode == ContentCardState.NoIcon:
|
|
680
|
+
self.hideIcon(True)
|
|
681
|
+
return
|
|
682
|
+
previous_state = self._card_state
|
|
683
|
+
self._width_mode = mode
|
|
684
|
+
self._update_layout_mode()
|
|
685
|
+
if mode in (ContentCardState.Wide, ContentCardState.Warp) and previous_state == mode:
|
|
686
|
+
# 状态真正变化时 _update_layout_mode 已经发过;这里补发“手动切到相同状态”
|
|
687
|
+
self.cardStateChanged.emit(mode)
|
|
688
|
+
|
|
689
|
+
def hideIcon(self, hidden: bool = True):
|
|
690
|
+
"""单独控制图标显隐(与宽 / 窄模式无关)。
|
|
691
|
+
|
|
692
|
+
hidden=True 时无论宽度都隐藏图标(优先于 Wide / Warp 模式);
|
|
693
|
+
hidden=False 恢复自动:强制模式(Wide / Warp)下直接显示,
|
|
694
|
+
Auto 模式下仍受 no_icon_threshold 限制。
|
|
695
|
+
图标显隐实际发生变化时发出 IconHided(bool)。
|
|
696
|
+
setCardMode(Wide / Warp / Auto) 不会改变这里的强制隐藏;要显示图标请调 hideIcon(False)。
|
|
697
|
+
"""
|
|
698
|
+
hidden = bool(hidden)
|
|
699
|
+
if hidden == self._hide_icon:
|
|
700
|
+
return
|
|
701
|
+
self._hide_icon = hidden
|
|
702
|
+
self._update_layout_mode()
|
|
703
|
+
|
|
704
|
+
def resizeEvent(self, event):
|
|
705
|
+
super().resizeEvent(event)
|
|
706
|
+
self._update_layout_mode()
|
|
707
|
+
|
|
708
|
+
def showEvent(self, event):
|
|
709
|
+
super().showEvent(event)
|
|
710
|
+
self._update_layout_mode()
|
|
711
|
+
|
|
712
|
+
@level_component(contribution=0)
|
|
713
|
+
class PressableRow(Card):
|
|
714
|
+
"""可按下的一行(下拉按钮 / 下拉菜单项):悬浮与按下变色,释放时触发槽函数。
|
|
715
|
+
|
|
716
|
+
参数:
|
|
717
|
+
layout —— 行内布局(挂载到 Card 上,边距由调用方决定)
|
|
718
|
+
on_click —— 释放时调用的槽函数;也可以连接 clicked 信号
|
|
719
|
+
variant —— 样式变体:
|
|
720
|
+
"default" 独立使用:无边框、透明背景、上下左右 8px 内边距、4px 圆角,
|
|
721
|
+
悬浮 / 按下只改背景色
|
|
722
|
+
"menu" 挂载在 Expander 下时使用:卡片底色 + 边框配色
|
|
723
|
+
|
|
724
|
+
挂载时切换样式(与 layout 挂载同理):Expander.add_layout 会把 PressableRow
|
|
725
|
+
切到 "menu" 变体;也可直接调用 set_variant()。
|
|
726
|
+
|
|
727
|
+
配色:普通 / 悬浮 / 按下三套 class 都在 qss 里
|
|
728
|
+
(.PressableRow / .PressableRowHover / .PressableRowPressed 与
|
|
729
|
+
.ExpanderRow / .ExpanderRowHover / .ExpanderRowPressed,见 Gui/res/styles/dark.qss),
|
|
730
|
+
代码只按状态与变体调用 set_style 切换 class。
|
|
731
|
+
|
|
732
|
+
信号:
|
|
733
|
+
clicked —— 在行内释放时触发(按下后拖出再拖回、仍在行内释放也算)
|
|
734
|
+
pressed —— 按下时触发(按住拖出后拖回会再次触发,与 QPushButton 一致)
|
|
735
|
+
released —— 松开或按住拖出时触发(与 QPushButton 一致)
|
|
736
|
+
|
|
737
|
+
示例:
|
|
738
|
+
row_layout = QHBoxLayout()
|
|
739
|
+
row_layout.addWidget(fonticon("\uE713", size=16))
|
|
740
|
+
row_layout.addWidget(TextLabel("菜单项"), 1)
|
|
741
|
+
row = PressableRow(layout=row_layout, on_click=lambda: print("clicked"))
|
|
742
|
+
row.clicked.connect(lambda: print("clicked signal"))
|
|
743
|
+
row.pressed.connect(lambda: print("pressed"))
|
|
744
|
+
row.released.connect(lambda: print("released"))
|
|
745
|
+
"""
|
|
746
|
+
|
|
747
|
+
clicked = Signal()
|
|
748
|
+
pressed = Signal()
|
|
749
|
+
released = Signal()
|
|
750
|
+
|
|
751
|
+
VARIANT_DEFAULT = "default"
|
|
752
|
+
VARIANT_MENU = "menu"
|
|
753
|
+
|
|
754
|
+
STYLE_CLASS = "PressableRow"
|
|
755
|
+
HOVER_CLASS = "PressableRowHover"
|
|
756
|
+
PRESSED_CLASS = "PressableRowPressed"
|
|
757
|
+
|
|
758
|
+
MENU_STYLE_CLASS = "ExpanderRow"
|
|
759
|
+
MENU_HOVER_CLASS = "ExpanderRowHover"
|
|
760
|
+
MENU_PRESSED_CLASS = "ExpanderRowPressed"
|
|
761
|
+
|
|
762
|
+
_CLASSES = {
|
|
763
|
+
VARIANT_DEFAULT: (STYLE_CLASS, HOVER_CLASS, PRESSED_CLASS),
|
|
764
|
+
VARIANT_MENU: (MENU_STYLE_CLASS, MENU_HOVER_CLASS, MENU_PRESSED_CLASS),
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
_BG = "#343434" # menu 变体默认配色(qss 未加载时的兜底值)
|
|
768
|
+
_BORDER = "#232323"
|
|
769
|
+
_RADIUS = 4 # 圆角(形状,不由 qss 控制)
|
|
770
|
+
_BORDER_WIDTH = 1 # menu 变体边框宽度(default 变体无边框)
|
|
771
|
+
_PADDING = 8 # default 变体上下左右内边距
|
|
772
|
+
|
|
773
|
+
def __init__(self, layout=None, on_click=None, parent=None,
|
|
774
|
+
variant=VARIANT_DEFAULT):
|
|
775
|
+
# Card.__init__ 会读取 _source_margins / variant 相关状态
|
|
776
|
+
self._variant = self._normalize_variant(variant)
|
|
777
|
+
self._source_margins = None
|
|
778
|
+
|
|
779
|
+
super().__init__(layout=layout, parent=parent)
|
|
780
|
+
|
|
781
|
+
self._on_click = on_click
|
|
782
|
+
self._hover = False
|
|
783
|
+
self._pressed = False
|
|
784
|
+
self._down = False
|
|
785
|
+
|
|
786
|
+
# 背景自绘(paintEvent),不需要 QSS 背景
|
|
787
|
+
self.setCursor(Qt.PointingHandCursor)
|
|
788
|
+
self._apply_variant()
|
|
789
|
+
|
|
790
|
+
# --------------------------- 变体 ---------------------------
|
|
791
|
+
@classmethod
|
|
792
|
+
def _normalize_variant(cls, variant) -> str:
|
|
793
|
+
if variant not in cls._CLASSES:
|
|
794
|
+
raise ValueError(f"未知的 PressableRow 变体:{variant!r}")
|
|
795
|
+
return variant
|
|
796
|
+
|
|
797
|
+
def variant(self) -> str:
|
|
798
|
+
return self._variant
|
|
799
|
+
|
|
800
|
+
def set_variant(self, variant):
|
|
801
|
+
"""切换样式变体(挂载到 Expander 时由 Expander 调用)。"""
|
|
802
|
+
variant = self._normalize_variant(variant)
|
|
803
|
+
if variant == self._variant:
|
|
804
|
+
return
|
|
805
|
+
self._variant = variant
|
|
806
|
+
self._apply_variant()
|
|
807
|
+
|
|
808
|
+
def _apply_variant(self):
|
|
809
|
+
"""按变体设置形状、内边距与配色 class。"""
|
|
810
|
+
if self._variant == self.VARIANT_MENU:
|
|
811
|
+
self._border_width = self._BORDER_WIDTH
|
|
812
|
+
else:
|
|
813
|
+
self._border_width = 0
|
|
814
|
+
self._radius = self._RADIUS
|
|
815
|
+
|
|
816
|
+
self._apply_padding()
|
|
817
|
+
self._sync_state_class()
|
|
818
|
+
self._sync_content_height()
|
|
819
|
+
self.updateGeometry()
|
|
820
|
+
self.update()
|
|
821
|
+
|
|
822
|
+
# --------------------------- 内边距 ---------------------------
|
|
823
|
+
def mount_layout(self, layout):
|
|
824
|
+
"""挂载布局时记录调用方边距,便于切回 menu 变体时恢复。"""
|
|
825
|
+
super().mount_layout(layout)
|
|
826
|
+
self._source_margins = layout.contentsMargins()
|
|
827
|
+
self._apply_padding()
|
|
828
|
+
|
|
829
|
+
def _apply_padding(self):
|
|
830
|
+
"""default 变体:上下左右 8px;menu 变体:恢复调用方设置的边距。"""
|
|
831
|
+
lay = self.layout()
|
|
832
|
+
if lay is None:
|
|
833
|
+
return
|
|
834
|
+
if self._variant == self.VARIANT_MENU:
|
|
835
|
+
if self._source_margins is None:
|
|
836
|
+
return
|
|
837
|
+
target = self._source_margins
|
|
838
|
+
else:
|
|
839
|
+
target = QMargins(self._PADDING, self._PADDING,
|
|
840
|
+
self._PADDING, self._PADDING)
|
|
841
|
+
if lay.contentsMargins() != target:
|
|
842
|
+
lay.setContentsMargins(target)
|
|
843
|
+
self.updateGeometry()
|
|
844
|
+
|
|
845
|
+
# --------------------------- 交互 ---------------------------
|
|
846
|
+
def set_on_click(self, on_click):
|
|
847
|
+
self._on_click = on_click
|
|
848
|
+
|
|
849
|
+
def _base_bg_color(self) -> QColor:
|
|
850
|
+
return QColor(self._BG) if self._variant == self.VARIANT_MENU else QColor(0, 0, 0, 0)
|
|
851
|
+
|
|
852
|
+
def _base_border_color(self) -> QColor:
|
|
853
|
+
return QColor(self._BORDER) if self._variant == self.VARIANT_MENU else QColor(0, 0, 0, 0)
|
|
854
|
+
|
|
855
|
+
def _sync_state_class(self):
|
|
856
|
+
"""按当前状态切换样式 class(颜色定义在 qss 里)。"""
|
|
857
|
+
normal, hover, pressed = self._CLASSES[self._variant]
|
|
858
|
+
if self._pressed:
|
|
859
|
+
set_style(self, pressed)
|
|
860
|
+
elif self._hover:
|
|
861
|
+
set_style(self, hover)
|
|
862
|
+
else:
|
|
863
|
+
# qss 未提供对应 class 时也要干净地回到常态配色
|
|
864
|
+
self._bg_color = self._base_bg_color()
|
|
865
|
+
self._border_color = self._base_border_color()
|
|
866
|
+
set_style(self, normal)
|
|
867
|
+
|
|
868
|
+
def _set_down(self, down: bool):
|
|
869
|
+
"""更新按下状态并发出 pressed / released(与 QPushButton 一致)。"""
|
|
870
|
+
down = bool(down)
|
|
871
|
+
if down == self._down:
|
|
872
|
+
return
|
|
873
|
+
self._down = down
|
|
874
|
+
self._pressed = down
|
|
875
|
+
self._sync_state_class()
|
|
876
|
+
if down:
|
|
877
|
+
self.pressed.emit()
|
|
878
|
+
else:
|
|
879
|
+
self.released.emit()
|
|
880
|
+
|
|
881
|
+
def enterEvent(self, event):
|
|
882
|
+
self._hover = True
|
|
883
|
+
self._sync_state_class()
|
|
884
|
+
super().enterEvent(event)
|
|
885
|
+
|
|
886
|
+
def leaveEvent(self, event):
|
|
887
|
+
self._hover = False
|
|
888
|
+
if not self._down:
|
|
889
|
+
self._pressed = False
|
|
890
|
+
self._sync_state_class()
|
|
891
|
+
super().leaveEvent(event)
|
|
892
|
+
|
|
893
|
+
def mousePressEvent(self, event):
|
|
894
|
+
if event.button() == Qt.LeftButton:
|
|
895
|
+
self._hover = True
|
|
896
|
+
if not self._down and self.rect().contains(event.position().toPoint()):
|
|
897
|
+
self._set_down(True)
|
|
898
|
+
super().mousePressEvent(event)
|
|
899
|
+
|
|
900
|
+
def mouseMoveEvent(self, event):
|
|
901
|
+
# 与 QPushButton 一致:按住拖出时 released、拖回时再次 pressed
|
|
902
|
+
if event.buttons() & Qt.LeftButton:
|
|
903
|
+
inside = self.rect().contains(event.position().toPoint())
|
|
904
|
+
if inside != self._down:
|
|
905
|
+
self._set_down(inside)
|
|
906
|
+
super().mouseMoveEvent(event)
|
|
907
|
+
|
|
908
|
+
def mouseReleaseEvent(self, event):
|
|
909
|
+
if event.button() == Qt.LeftButton and self._down:
|
|
910
|
+
inside = self.rect().contains(event.position().toPoint())
|
|
911
|
+
self._set_down(False)
|
|
912
|
+
if inside:
|
|
913
|
+
self._emit_click()
|
|
914
|
+
super().mouseReleaseEvent(event)
|
|
915
|
+
|
|
916
|
+
def _emit_click(self):
|
|
917
|
+
self.clicked.emit()
|
|
918
|
+
if self._on_click is not None:
|
|
919
|
+
self._on_click()
|
|
920
|
+
|
|
921
|
+
def trigger(self):
|
|
922
|
+
"""程序化触发一次点击(等价 QPushButton.click():pressed → released → clicked)。"""
|
|
923
|
+
self._set_down(True)
|
|
924
|
+
self._set_down(False)
|
|
925
|
+
self._emit_click()
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
class _Divider(QFrame):
|
|
929
|
+
"""下拉内容之间的 1px 分割线。
|
|
930
|
+
|
|
931
|
+
颜色默认值见 _COLOR,可由 qss 里的
|
|
932
|
+
`.ExpanderDivider { qproperty-color: ... }` 覆盖(见 Gui/res/styles/dark.qss)。
|
|
933
|
+
|
|
934
|
+
示例:
|
|
935
|
+
line = _Divider()
|
|
936
|
+
line.setFixedHeight(1)
|
|
937
|
+
line.set_color("#3A3A3A") # 或交给 qss 的 .ExpanderDivider
|
|
938
|
+
"""
|
|
939
|
+
|
|
940
|
+
STYLE_CLASS = "ExpanderDivider"
|
|
941
|
+
|
|
942
|
+
_COLOR = "#232323"
|
|
943
|
+
|
|
944
|
+
def __init__(self, parent=None):
|
|
945
|
+
super().__init__(parent)
|
|
946
|
+
self._color = QColor(self._COLOR)
|
|
947
|
+
self._apply_color()
|
|
948
|
+
set_style(self, self.STYLE_CLASS)
|
|
949
|
+
|
|
950
|
+
def _apply_color(self):
|
|
951
|
+
self.setStyleSheet(f"background-color: {self._color.name()}; border: none;")
|
|
952
|
+
|
|
953
|
+
def get_color(self) -> QColor:
|
|
954
|
+
return self._color
|
|
955
|
+
|
|
956
|
+
def set_color(self, color):
|
|
957
|
+
color = QColor(color)
|
|
958
|
+
if color != self._color:
|
|
959
|
+
self._color = color
|
|
960
|
+
self._apply_color()
|
|
961
|
+
|
|
962
|
+
color = Property(QColor, get_color, set_color)
|
|
963
|
+
|
|
964
|
+
def _add_smart(layout, obj, stretch: int = 0):
|
|
965
|
+
"""把一个控件 / 布局 / 布局项添加到布局里(按类型自动选择 addWidget / addLayout / addItem)。
|
|
966
|
+
|
|
967
|
+
示例:
|
|
968
|
+
row = QHBoxLayout()
|
|
969
|
+
add_smart(row, fonticon("\uE799")) # QWidget → addWidget
|
|
970
|
+
add_smart(row, QVBoxLayout(), stretch=1) # QLayout → addLayout
|
|
971
|
+
"""
|
|
972
|
+
if isinstance(obj, QWidget):
|
|
973
|
+
layout.addWidget(obj, stretch)
|
|
974
|
+
elif isinstance(obj, QLayout):
|
|
975
|
+
layout.addLayout(obj, stretch)
|
|
976
|
+
elif isinstance(obj, QLayoutItem):
|
|
977
|
+
layout.addItem(obj)
|
|
978
|
+
else:
|
|
979
|
+
raise TypeError(f"add_smart not supported type: {type(obj).__name__}")
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _iter_labels(widget: QWidget):
|
|
983
|
+
"""遍历控件自身(若是文字标签)及其内部的文字标签。"""
|
|
984
|
+
if isinstance(widget, QLabel):
|
|
985
|
+
yield widget
|
|
986
|
+
yield from widget.findChildren(QLabel)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _one_char_width(label: QLabel) -> int:
|
|
990
|
+
"""一个字符的最小宽度(一行至少能放一个字符)。"""
|
|
991
|
+
fm = label.fontMetrics()
|
|
992
|
+
return max(1, fm.horizontalAdvance("M"), fm.horizontalAdvance("字"))
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _as_side_widget(obj, hfw: bool = False):
|
|
996
|
+
"""把一侧内容统一成可设置尺寸策略的控件;spacer 等无法包装的对象原样返回。
|
|
997
|
+
|
|
998
|
+
传入 QLayout 时会包进容器:外层行已提供内边距,内部布局的默认 9px 边距
|
|
999
|
+
(Qt 默认值)必须清零,否则那一侧会比纯控件高 18px,内容还会内缩。
|
|
1000
|
+
hfw=True 用支持 heightForWidth 的容器(供左侧换行压缩使用)。
|
|
1001
|
+
"""
|
|
1002
|
+
if isinstance(obj, QWidget):
|
|
1003
|
+
return obj
|
|
1004
|
+
if isinstance(obj, QLayout):
|
|
1005
|
+
container = _HFWContainer() if hfw else QWidget()
|
|
1006
|
+
obj.setContentsMargins(0, 0, 0, 0)
|
|
1007
|
+
container.setLayout(obj)
|
|
1008
|
+
return container
|
|
1009
|
+
if isinstance(obj, QLayoutItem):
|
|
1010
|
+
widget = obj.widget()
|
|
1011
|
+
if widget is not None:
|
|
1012
|
+
return widget
|
|
1013
|
+
layout = obj.layout()
|
|
1014
|
+
if layout is not None:
|
|
1015
|
+
return _as_side_widget(layout, hfw)
|
|
1016
|
+
return obj
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _prepare_expander_side(obj, compressible: bool):
|
|
1020
|
+
"""按左右两侧的不同需求设置尺寸策略。
|
|
1021
|
+
|
|
1022
|
+
左侧(compressible=True):文字自动换行,可一路压缩到一行一个字符;
|
|
1023
|
+
右侧(compressible=False):保持原有尺寸,不随可用宽度收缩。
|
|
1024
|
+
"""
|
|
1025
|
+
widget = _as_side_widget(obj, hfw=compressible)
|
|
1026
|
+
if not isinstance(widget, QWidget):
|
|
1027
|
+
return obj
|
|
1028
|
+
|
|
1029
|
+
if compressible:
|
|
1030
|
+
for label in _iter_labels(widget):
|
|
1031
|
+
label.setWordWrap(True)
|
|
1032
|
+
label.setMinimumWidth(_one_char_width(label))
|
|
1033
|
+
label.setSizePolicy(_hfw_policy(QSizePolicy.Preferred, QSizePolicy.Preferred))
|
|
1034
|
+
if isinstance(widget, QLabel):
|
|
1035
|
+
widget.setSizePolicy(_hfw_policy(QSizePolicy.Preferred, QSizePolicy.Preferred))
|
|
1036
|
+
else:
|
|
1037
|
+
policy = widget.sizePolicy()
|
|
1038
|
+
policy.setHorizontalPolicy(QSizePolicy.Preferred)
|
|
1039
|
+
lay = widget.layout()
|
|
1040
|
+
# 只有真正支持 heightForWidth 的内容才开启,否则布局会算出错误高度
|
|
1041
|
+
policy.setHeightForWidth(bool(lay is not None and lay.hasHeightForWidth()))
|
|
1042
|
+
widget.setSizePolicy(policy)
|
|
1043
|
+
# 容器最小宽度 = 内部内容的最小宽度(文字已按一个字符为下限)
|
|
1044
|
+
widget.setMinimumWidth(widget.minimumSizeHint().width())
|
|
1045
|
+
else:
|
|
1046
|
+
# 不换行 + Fixed:sizeHint 即上限,右侧不会被压缩
|
|
1047
|
+
for label in _iter_labels(widget):
|
|
1048
|
+
label.setWordWrap(False)
|
|
1049
|
+
label_policy = label.sizePolicy()
|
|
1050
|
+
label_policy.setHeightForWidth(False)
|
|
1051
|
+
label.setSizePolicy(label_policy)
|
|
1052
|
+
policy = widget.sizePolicy()
|
|
1053
|
+
policy.setHorizontalPolicy(QSizePolicy.Fixed)
|
|
1054
|
+
policy.setHeightForWidth(False)
|
|
1055
|
+
widget.setSizePolicy(policy)
|
|
1056
|
+
return widget
|
|
1057
|
+
|
|
1058
|
+
def create_expander_row(left_widget, right_widget, press_command=None) -> PressableRow | QLayout:
|
|
1059
|
+
'''
|
|
1060
|
+
创建一个两边布局的项目
|
|
1061
|
+
|
|
1062
|
+
左侧按可用宽度自动换行压缩(最小压缩到一行一个字符),右侧保持原尺寸不被压缩。
|
|
1063
|
+
|
|
1064
|
+
参数:
|
|
1065
|
+
left_widget —— 左侧组件
|
|
1066
|
+
right_widget —— 右侧组件
|
|
1067
|
+
press_command —— 按下的操作,为None不注册
|
|
1068
|
+
|
|
1069
|
+
示例:
|
|
1070
|
+
row = create_expander_row(
|
|
1071
|
+
TextLabel("Wi-Fi"), # 左侧:可压缩、自动换行
|
|
1072
|
+
fonticon("\uE72E", size=14), # 右侧:保持原尺寸
|
|
1073
|
+
press_command=lambda: print("clicked"), # 传入则返回 PressableRow
|
|
1074
|
+
)
|
|
1075
|
+
expander = Expander(layouts=[row])
|
|
1076
|
+
'''
|
|
1077
|
+
layout = QHBoxLayout()
|
|
1078
|
+
layout.setContentsMargins(60, 20, 16, 20)
|
|
1079
|
+
|
|
1080
|
+
_add_smart(layout, _prepare_expander_side(left_widget, compressible=True), stretch=1)
|
|
1081
|
+
_add_smart(layout, _prepare_expander_side(right_widget, compressible=False), stretch=0)
|
|
1082
|
+
|
|
1083
|
+
if press_command:
|
|
1084
|
+
return PressableRow(layout=layout, on_click=press_command)
|
|
1085
|
+
else:
|
|
1086
|
+
return layout
|
|
1087
|
+
|
|
1088
|
+
class Expander(Card):
|
|
1089
|
+
"""下拉框(Expander):标题行常显,下拉区可挂载多个布局,1px 分割线分隔。
|
|
1090
|
+
|
|
1091
|
+
参数:
|
|
1092
|
+
title —— 图标 QLabel(用 fonticon() 生成);None 表示无图标
|
|
1093
|
+
subtitle —— 标题文字
|
|
1094
|
+
description —— 描述文字
|
|
1095
|
+
layouts —— 下拉内容列表,元素可以是 QLayout,也可以是 QWidget
|
|
1096
|
+
(例如 PressableRow,按下触发槽函数;挂载时会自动切到
|
|
1097
|
+
「菜单」样式变体);相邻两项间自动插入 1px 分割线
|
|
1098
|
+
expanded —— 初始是否展开(默认收起)
|
|
1099
|
+
duration —— 展开 / 收起动画时长(ms),<= 0 表示立即切换
|
|
1100
|
+
right_layout —— 自定义右侧布局(可选),放在下拉箭头左侧,间隔 8px;
|
|
1101
|
+
用法与 ContentCard 的 right_layout 一致
|
|
1102
|
+
|
|
1103
|
+
信号:
|
|
1104
|
+
toggled(bool) —— 展开状态变化后发出(旧信号,保留兼容)
|
|
1105
|
+
expandStateChanged(bool) —— 展开状态变化后发出,bool 为变化后的展开状态
|
|
1106
|
+
|
|
1107
|
+
配色:下拉区底色 / 文字 / 箭头 / 分割线的颜色都在 qss 里
|
|
1108
|
+
(.Expander、.ExpanderSubtitle、.ExpanderDescription、.ExpanderArrow、
|
|
1109
|
+
.ExpanderDivider,见 Gui/res/styles/dark.qss),代码只用 set_style 挂 class,
|
|
1110
|
+
具体颜色值不写在代码里。
|
|
1111
|
+
|
|
1112
|
+
布局(不随宽度自适应缩放):
|
|
1113
|
+
16px │ 图标(跨两行) 20px 标题 ···· [右侧布局] 8px 箭头(居右) │ 16px
|
|
1114
|
+
│ 8px
|
|
1115
|
+
│ 描述
|
|
1116
|
+
16px
|
|
1117
|
+
(图标、文字列、右侧布局、箭头都在竖直方向居中)
|
|
1118
|
+
|
|
1119
|
+
组件层级:Expander 继承 Card(贡献 +1),所以标题栏(PressableRow)和
|
|
1120
|
+
下拉区的每个布局 / 控件都处于 1 级;Expander 自身是否加层由它所在
|
|
1121
|
+
的父组件决定(挂在页面根上时 raw=0)。
|
|
1122
|
+
|
|
1123
|
+
示例:
|
|
1124
|
+
expander = Expander(
|
|
1125
|
+
title=fonticon("\uE713", size=20),
|
|
1126
|
+
subtitle="设置",
|
|
1127
|
+
description="网络 / 蓝牙 / 飞行模式",
|
|
1128
|
+
layouts=[
|
|
1129
|
+
create_expander_row(TextLabel("Wi-Fi"), fonticon("\uE72E", size=14)),
|
|
1130
|
+
create_expander_row(TextLabel("蓝牙"), TextLabel("关", "#9A9A9A", 12)),
|
|
1131
|
+
],
|
|
1132
|
+
expanded=False,
|
|
1133
|
+
)
|
|
1134
|
+
expander.expandStateChanged.connect(
|
|
1135
|
+
lambda expanded: print("展开状态:", expanded))
|
|
1136
|
+
"""
|
|
1137
|
+
|
|
1138
|
+
_PAD = 16 # 上下左右内边距
|
|
1139
|
+
_GAP = 4 # 基础间距
|
|
1140
|
+
_ICON_GAP = 20 # 图标 ↔ 文字列
|
|
1141
|
+
_ROW_GAP = 2 # 8px(2 个 Gap),标题 ↔ 描述
|
|
1142
|
+
_RIGHT_GAP = 8 # 右侧布局 ↔ 箭头
|
|
1143
|
+
_DURATION = 250 # 展开 / 收起动画时长
|
|
1144
|
+
_ARROW_ICON = "\uE70D"
|
|
1145
|
+
_ARROW_SIZE = 12
|
|
1146
|
+
|
|
1147
|
+
_SUBTITLE_COLOR = "#FFFFFF" # 默认配色,可由 qss 的 .ExpanderSubtitle 覆盖
|
|
1148
|
+
_DESC_COLOR = "#D1D1D1" # 默认配色,可由 qss 的 .ExpanderDescription 覆盖
|
|
1149
|
+
_SUBTITLE_SIZE = 14 # 字号(写进标签自身样式表,不受全局 `*` 影响)
|
|
1150
|
+
_DESC_SIZE = 12
|
|
1151
|
+
|
|
1152
|
+
STYLE_CLASS = "Expander"
|
|
1153
|
+
ARROW_CLASS = "ExpanderArrow"
|
|
1154
|
+
|
|
1155
|
+
_BG = "#323232" # 下拉菜单底色默认值(可由 qss 的 .Expander 覆盖)
|
|
1156
|
+
_BORDER = "#232323"
|
|
1157
|
+
|
|
1158
|
+
toggled = Signal(bool)
|
|
1159
|
+
expandStateChanged = Signal(bool)
|
|
1160
|
+
|
|
1161
|
+
def __init__(self, title=None, subtitle="", description="", layouts=None,
|
|
1162
|
+
expanded=False, duration=None, right_layout=None, parent=None):
|
|
1163
|
+
self._expanded = bool(expanded)
|
|
1164
|
+
self._duration = self._DURATION if duration is None else int(duration)
|
|
1165
|
+
|
|
1166
|
+
# ================= 根布局(交给 Card 基类挂载) =================
|
|
1167
|
+
self._root = QVBoxLayout()
|
|
1168
|
+
self._root.setContentsMargins(0, 0, 0, 0)
|
|
1169
|
+
self._root.setSpacing(0)
|
|
1170
|
+
|
|
1171
|
+
super().__init__(layout=self._root, parent=parent)
|
|
1172
|
+
|
|
1173
|
+
# Card 底色即下拉菜单背景(#323232 / #232323);
|
|
1174
|
+
# 标题行(PressableRow)自带下拉按钮配色
|
|
1175
|
+
|
|
1176
|
+
# ================= 标题行 =================
|
|
1177
|
+
self._title_label = _as_icon_label(title)
|
|
1178
|
+
self._arrow = FontIcon(self._ARROW_ICON, size=self._ARROW_SIZE)
|
|
1179
|
+
set_style(self._arrow, self.ARROW_CLASS)
|
|
1180
|
+
|
|
1181
|
+
self._header = PressableRow(layout=self._build_header_layout(subtitle, description),
|
|
1182
|
+
on_click=self.toggle, parent=self,
|
|
1183
|
+
variant=PressableRow.VARIANT_MENU)
|
|
1184
|
+
self._root.addWidget(self._header)
|
|
1185
|
+
if right_layout is not None:
|
|
1186
|
+
self.set_right_layout(right_layout)
|
|
1187
|
+
self._apply_right_margins()
|
|
1188
|
+
|
|
1189
|
+
# ================= 下拉区(透明,露出 Card 的菜单底色) =================
|
|
1190
|
+
self._items = []
|
|
1191
|
+
self._create_body()
|
|
1192
|
+
for obj in layouts or []:
|
|
1193
|
+
self.add_layout(obj)
|
|
1194
|
+
|
|
1195
|
+
# ================= 动画 =================
|
|
1196
|
+
self._arrow_anim = QPropertyAnimation(self._arrow, b"angle", self)
|
|
1197
|
+
self._arrow_anim.setDuration(self._duration)
|
|
1198
|
+
self._arrow_anim.setStartValue(0.0)
|
|
1199
|
+
self._arrow_anim.setEndValue(180.0)
|
|
1200
|
+
|
|
1201
|
+
self._body_anim = QPropertyAnimation(self._body, b"maximumHeight", self)
|
|
1202
|
+
self._body_anim.setDuration(self._duration)
|
|
1203
|
+
self._body_anim.finished.connect(self._on_body_anim_finished)
|
|
1204
|
+
|
|
1205
|
+
self._sync_content_state()
|
|
1206
|
+
self._apply_expanded_state()
|
|
1207
|
+
|
|
1208
|
+
# --------------------------- 标题行布局 ---------------------------
|
|
1209
|
+
def _build_header_layout(self, subtitle, description) -> QHBoxLayout:
|
|
1210
|
+
# 标题行:只有标题(箭头放到整行右侧,见下方 box)
|
|
1211
|
+
title_row = QHBoxLayout()
|
|
1212
|
+
title_row.setContentsMargins(0, 0, 0, 0)
|
|
1213
|
+
title_row.setSpacing(0)
|
|
1214
|
+
|
|
1215
|
+
self._subtitle_label = TextLabel(_normalize_text(subtitle), self._SUBTITLE_COLOR,
|
|
1216
|
+
self._SUBTITLE_SIZE)
|
|
1217
|
+
_prep_text_label(self._subtitle_label)
|
|
1218
|
+
set_style(self._subtitle_label, "ExpanderSubtitle")
|
|
1219
|
+
|
|
1220
|
+
self._desc_label = TextLabel(_normalize_text(description), self._DESC_COLOR,
|
|
1221
|
+
self._DESC_SIZE)
|
|
1222
|
+
_prep_text_label(self._desc_label)
|
|
1223
|
+
set_style(self._desc_label, "ExpanderDescription")
|
|
1224
|
+
|
|
1225
|
+
title_row.addWidget(self._subtitle_label, 1, Qt.AlignVCenter)
|
|
1226
|
+
|
|
1227
|
+
# 文字列容器:全空时宽度收敛为 0,但仍吸收拉伸,
|
|
1228
|
+
# 保证右侧布局与箭头始终贴右(与 ContentCard 的中间列同思路)
|
|
1229
|
+
self._text_container = _HFWContainer()
|
|
1230
|
+
self._text_container.setSizePolicy(
|
|
1231
|
+
_hfw_policy(QSizePolicy.Expanding, QSizePolicy.Preferred))
|
|
1232
|
+
self._text_container.setMinimumWidth(0)
|
|
1233
|
+
|
|
1234
|
+
# 文字列:图标右侧 20px,标题 ↔ 描述 8px
|
|
1235
|
+
self._text_layout = QVBoxLayout(self._text_container)
|
|
1236
|
+
self._text_layout.setContentsMargins(self._ICON_GAP, 0, 0, 0)
|
|
1237
|
+
self._text_layout.setSpacing(self._ROW_GAP)
|
|
1238
|
+
self._text_layout.addLayout(title_row)
|
|
1239
|
+
self._text_layout.addWidget(self._desc_label, 0, Qt.AlignVCenter)
|
|
1240
|
+
|
|
1241
|
+
# 右侧自定义布局容器(可选)
|
|
1242
|
+
self._right_container = QWidget()
|
|
1243
|
+
self._right_container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
|
1244
|
+
self._right_container.setMinimumWidth(0)
|
|
1245
|
+
|
|
1246
|
+
# 箭头容器:只在有右侧布局时留出 8px,避免箭头被推离右边缘
|
|
1247
|
+
self._arrow_container = QWidget()
|
|
1248
|
+
self._arrow_container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
|
1249
|
+
self._arrow_layout = QHBoxLayout(self._arrow_container)
|
|
1250
|
+
self._arrow_layout.setContentsMargins(0, 0, 0, 0)
|
|
1251
|
+
self._arrow_layout.setSpacing(0)
|
|
1252
|
+
self._arrow_layout.addWidget(self._arrow, 0, Qt.AlignVCenter)
|
|
1253
|
+
|
|
1254
|
+
# 标题行:图标 / 文字列 / 右侧布局 / 箭头(居右),都在竖直方向居中
|
|
1255
|
+
box = QHBoxLayout()
|
|
1256
|
+
box.setContentsMargins(self._PAD, self._PAD, self._PAD, self._PAD)
|
|
1257
|
+
box.setSpacing(0)
|
|
1258
|
+
if self._title_label is not None:
|
|
1259
|
+
box.addWidget(self._title_label, 0, Qt.AlignVCenter)
|
|
1260
|
+
box.addWidget(self._text_container, 1)
|
|
1261
|
+
box.addWidget(self._right_container, 0, Qt.AlignVCenter)
|
|
1262
|
+
box.addWidget(self._arrow_container, 0, Qt.AlignVCenter)
|
|
1263
|
+
return box
|
|
1264
|
+
|
|
1265
|
+
def _sync_content_height(self):
|
|
1266
|
+
"""Expander 自身高度由展开 / 收起动画控制,不能锁自己的最小高度。
|
|
1267
|
+
|
|
1268
|
+
否则展开时会把最小高度锁成「标题行 + 下拉内容」,收起后父布局再也压不下去,
|
|
1269
|
+
腾出来的空间会被标题行吸收 —— 看起来就是「按钮变高到下拉框的高度」
|
|
1270
|
+
而不是下拉菜单向上收回。
|
|
1271
|
+
这里只需要保证标题行(内部是 Card)不被压扁即可。
|
|
1272
|
+
"""
|
|
1273
|
+
header = getattr(self, "_header", None) # __init__ 早于标题行创建
|
|
1274
|
+
if header is not None:
|
|
1275
|
+
header._sync_content_height()
|
|
1276
|
+
|
|
1277
|
+
# --------------------------- 右侧自定义布局 ---------------------------
|
|
1278
|
+
def set_right_layout(self, layout):
|
|
1279
|
+
"""设置右侧布局(箭头左侧,间隔 8px);会替换已有布局。"""
|
|
1280
|
+
old = self._right_container.layout()
|
|
1281
|
+
if old is not None and old is not layout:
|
|
1282
|
+
while old.count():
|
|
1283
|
+
item = old.takeAt(0)
|
|
1284
|
+
widget = item.widget()
|
|
1285
|
+
if widget is not None:
|
|
1286
|
+
widget.setParent(None)
|
|
1287
|
+
QWidget().setLayout(old)
|
|
1288
|
+
|
|
1289
|
+
self._right_container.setLayout(layout)
|
|
1290
|
+
self._apply_right_margins()
|
|
1291
|
+
self._sync_content_height()
|
|
1292
|
+
|
|
1293
|
+
def right_layout(self):
|
|
1294
|
+
return self._right_container.layout()
|
|
1295
|
+
|
|
1296
|
+
def right_container(self) -> QWidget:
|
|
1297
|
+
return self._right_container
|
|
1298
|
+
|
|
1299
|
+
def _apply_right_margins(self):
|
|
1300
|
+
"""Expander 统一管理右侧间距(与 ContentCard 一致,不留用户 layout 的外部边距):
|
|
1301
|
+
- 右侧布局:边距归零
|
|
1302
|
+
- 箭头:与右侧布局之间 8px;没有右侧布局时不留空隙
|
|
1303
|
+
"""
|
|
1304
|
+
lay = self._right_container.layout()
|
|
1305
|
+
if lay is not None:
|
|
1306
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
1307
|
+
|
|
1308
|
+
m = self._arrow_layout.contentsMargins()
|
|
1309
|
+
left = self._RIGHT_GAP if lay is not None else 0
|
|
1310
|
+
if m.left() != left:
|
|
1311
|
+
self._arrow_layout.setContentsMargins(left, m.top(), m.right(), m.bottom())
|
|
1312
|
+
|
|
1313
|
+
# --------------------------- 内容可见性 ---------------------------
|
|
1314
|
+
def _sync_content_state(self):
|
|
1315
|
+
"""空的 title / subtitle / description 对应布局项隐藏(有图片也算有内容)。"""
|
|
1316
|
+
self._has_title = _has_visual_content(self._title_label)
|
|
1317
|
+
self._has_subtitle = _has_visual_content(self._subtitle_label)
|
|
1318
|
+
self._has_description = _has_visual_content(self._desc_label)
|
|
1319
|
+
|
|
1320
|
+
if self._title_label is not None:
|
|
1321
|
+
self._title_label.setVisible(self._has_title)
|
|
1322
|
+
self._subtitle_label.setVisible(self._has_subtitle)
|
|
1323
|
+
self._desc_label.setVisible(self._has_description)
|
|
1324
|
+
|
|
1325
|
+
# 图标不可见、或没有任何文字时,文字列不再保留 20px 缩进
|
|
1326
|
+
has_text = self._has_subtitle or self._has_description
|
|
1327
|
+
left = self._ICON_GAP if (self._has_title and has_text) else 0
|
|
1328
|
+
m = self._text_layout.contentsMargins()
|
|
1329
|
+
if m.left() != left:
|
|
1330
|
+
self._text_layout.setContentsMargins(left, m.top(), m.right(), m.bottom())
|
|
1331
|
+
self._text_container.updateGeometry()
|
|
1332
|
+
self.updateGeometry()
|
|
1333
|
+
self._sync_content_height()
|
|
1334
|
+
|
|
1335
|
+
# --------------------------- 下拉内容 ---------------------------
|
|
1336
|
+
def _create_body(self):
|
|
1337
|
+
"""新建下拉区容器(旧容器连同其中的布局 / 控件一并销毁)。"""
|
|
1338
|
+
old = getattr(self, "_body", None)
|
|
1339
|
+
self._body = QWidget(self)
|
|
1340
|
+
self._body_layout = QVBoxLayout(self._body)
|
|
1341
|
+
self._body_layout.setContentsMargins(0, 0, 0, 0)
|
|
1342
|
+
self._body_layout.setSpacing(0)
|
|
1343
|
+
self._root.addWidget(self._body)
|
|
1344
|
+
|
|
1345
|
+
if old is not None:
|
|
1346
|
+
self._root.removeWidget(old)
|
|
1347
|
+
old.setParent(None)
|
|
1348
|
+
old.deleteLater()
|
|
1349
|
+
anim = getattr(self, "_body_anim", None)
|
|
1350
|
+
if anim is not None:
|
|
1351
|
+
anim.setTargetObject(self._body)
|
|
1352
|
+
|
|
1353
|
+
def set_layouts(self, layouts):
|
|
1354
|
+
"""重建下拉内容:元素为 QLayout 或 QWidget,中间自动插入 1px 分割线。"""
|
|
1355
|
+
self._items = []
|
|
1356
|
+
self._create_body()
|
|
1357
|
+
for obj in layouts or []:
|
|
1358
|
+
self.add_layout(obj)
|
|
1359
|
+
|
|
1360
|
+
def add_layout(self, obj):
|
|
1361
|
+
"""追加一个下拉项(自动补 1px 分割线)。"""
|
|
1362
|
+
# 挂载时切换样式:Expander 内的 PressableRow 使用菜单配色
|
|
1363
|
+
if isinstance(obj, PressableRow):
|
|
1364
|
+
obj.set_variant(PressableRow.VARIANT_MENU)
|
|
1365
|
+
if self._items:
|
|
1366
|
+
self._body_layout.addWidget(self._make_divider())
|
|
1367
|
+
if isinstance(obj, QLayout):
|
|
1368
|
+
self._body_layout.addLayout(obj)
|
|
1369
|
+
elif isinstance(obj, QWidget):
|
|
1370
|
+
self._body_layout.addWidget(obj)
|
|
1371
|
+
else:
|
|
1372
|
+
raise TypeError("layouts 的元素必须是 QLayout 或 QWidget")
|
|
1373
|
+
self._items.append(obj)
|
|
1374
|
+
self._resize_body()
|
|
1375
|
+
|
|
1376
|
+
def layouts(self):
|
|
1377
|
+
"""当前下拉项列表。"""
|
|
1378
|
+
return list(self._items)
|
|
1379
|
+
|
|
1380
|
+
@staticmethod
|
|
1381
|
+
def _make_divider() -> "_Divider":
|
|
1382
|
+
line = _Divider()
|
|
1383
|
+
line.setFixedHeight(1)
|
|
1384
|
+
line.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
1385
|
+
return line
|
|
1386
|
+
|
|
1387
|
+
def _body_height(self) -> int:
|
|
1388
|
+
"""展开后的内容高度。
|
|
1389
|
+
|
|
1390
|
+
文字换行时内容高度随宽度变化,因此优先用布局的 heightForWidth;
|
|
1391
|
+
没有 heightForWidth 的内容退回 sizeHint。
|
|
1392
|
+
"""
|
|
1393
|
+
lay = self._body_layout
|
|
1394
|
+
hint = lay.sizeHint().height()
|
|
1395
|
+
if lay.hasHeightForWidth():
|
|
1396
|
+
# 下拉区与 Expander 同宽(根布局无边距),resizeEvent 里 body 的宽度还是旧值,
|
|
1397
|
+
# 因此优先用自身的宽度计算
|
|
1398
|
+
width = self.width() if self.width() > 0 else self._body.width()
|
|
1399
|
+
if width > 0:
|
|
1400
|
+
return max(hint, lay.heightForWidth(width))
|
|
1401
|
+
return hint
|
|
1402
|
+
|
|
1403
|
+
def _resize_body(self):
|
|
1404
|
+
"""收起时高度锁 0,展开时按内容高度。"""
|
|
1405
|
+
self._body.setMaximumHeight(self._body_height() if self._expanded else 0)
|
|
1406
|
+
|
|
1407
|
+
def resizeEvent(self, event):
|
|
1408
|
+
super().resizeEvent(event)
|
|
1409
|
+
# 宽度变化会改变换行内容的高度,展开状态下需要同步下拉区高度
|
|
1410
|
+
anim = getattr(self, "_body_anim", None)
|
|
1411
|
+
if (self._expanded and anim is not None
|
|
1412
|
+
and anim.state() != QAbstractAnimation.Running):
|
|
1413
|
+
self._resize_body()
|
|
1414
|
+
|
|
1415
|
+
# --------------------------- 展开 / 收起 ---------------------------
|
|
1416
|
+
def is_expanded(self) -> bool:
|
|
1417
|
+
return self._expanded
|
|
1418
|
+
|
|
1419
|
+
def toggle(self):
|
|
1420
|
+
self.set_expanded(not self._expanded)
|
|
1421
|
+
|
|
1422
|
+
def set_expanded(self, expanded: bool, animate: bool = True):
|
|
1423
|
+
expanded = bool(expanded)
|
|
1424
|
+
if expanded == self._expanded:
|
|
1425
|
+
return
|
|
1426
|
+
self._expanded = expanded
|
|
1427
|
+
|
|
1428
|
+
if animate and self._duration > 0:
|
|
1429
|
+
self._animate_to(expanded)
|
|
1430
|
+
else:
|
|
1431
|
+
self._apply_expanded_state()
|
|
1432
|
+
self.toggled.emit(expanded)
|
|
1433
|
+
self.expandStateChanged.emit(expanded)
|
|
1434
|
+
|
|
1435
|
+
def _apply_expanded_state(self):
|
|
1436
|
+
"""不做动画,直接切到当前展开状态。"""
|
|
1437
|
+
self._body_anim.stop()
|
|
1438
|
+
self._arrow_anim.stop()
|
|
1439
|
+
self._body.setVisible(self._expanded)
|
|
1440
|
+
self._resize_body()
|
|
1441
|
+
self._arrow.set_angle(180.0 if self._expanded else 0.0)
|
|
1442
|
+
|
|
1443
|
+
def _animate_to(self, expanded: bool):
|
|
1444
|
+
self._body_anim.stop()
|
|
1445
|
+
if expanded:
|
|
1446
|
+
self._body.setVisible(True)
|
|
1447
|
+
self._body.setMaximumHeight(0)
|
|
1448
|
+
self._body_anim.setStartValue(0)
|
|
1449
|
+
self._body_anim.setEndValue(self._body_height())
|
|
1450
|
+
self._arrow_anim.setDirection(QAbstractAnimation.Forward)
|
|
1451
|
+
else:
|
|
1452
|
+
self._body_anim.setStartValue(self._body.height())
|
|
1453
|
+
self._body_anim.setEndValue(0)
|
|
1454
|
+
self._arrow_anim.setDirection(QAbstractAnimation.Backward)
|
|
1455
|
+
self._body_anim.start()
|
|
1456
|
+
self._arrow_anim.start()
|
|
1457
|
+
|
|
1458
|
+
def _on_body_anim_finished(self):
|
|
1459
|
+
if self._expanded:
|
|
1460
|
+
self._body.setMaximumHeight(16777215) # QWIDGETSIZE_MAX
|
|
1461
|
+
else:
|
|
1462
|
+
self._body.setVisible(False)
|
|
1463
|
+
|
|
1464
|
+
# --------------------------- 文字自定义 ---------------------------
|
|
1465
|
+
def set_title(self, title: Optional[Union[QLabel, str]]):
|
|
1466
|
+
label = _as_icon_label(title)
|
|
1467
|
+
old = self._title_label
|
|
1468
|
+
if label is not old:
|
|
1469
|
+
header = self._header.layout()
|
|
1470
|
+
index = header.indexOf(old) if old is not None else 0
|
|
1471
|
+
if old is not None:
|
|
1472
|
+
header.removeWidget(old)
|
|
1473
|
+
old.setParent(None)
|
|
1474
|
+
self._title_label = label
|
|
1475
|
+
if label is not None:
|
|
1476
|
+
header.insertWidget(index, label, 0, Qt.AlignVCenter)
|
|
1477
|
+
self._sync_content_state()
|
|
1478
|
+
|
|
1479
|
+
def title(self) -> Optional[QLabel]:
|
|
1480
|
+
return self._title_label
|
|
1481
|
+
|
|
1482
|
+
def set_subtitle(self, text: Optional[str]):
|
|
1483
|
+
self._subtitle_label.setText(_normalize_text(text))
|
|
1484
|
+
self._sync_content_state()
|
|
1485
|
+
|
|
1486
|
+
def subtitle(self) -> str:
|
|
1487
|
+
return self._subtitle_label.text()
|
|
1488
|
+
|
|
1489
|
+
def set_description(self, text: Optional[str]):
|
|
1490
|
+
self._desc_label.setText(_normalize_text(text))
|
|
1491
|
+
self._sync_content_state()
|
|
1492
|
+
|
|
1493
|
+
def description(self) -> str:
|
|
1494
|
+
return self._desc_label.text()
|
|
1495
|
+
|
|
1496
|
+
def header(self) -> PressableRow:
|
|
1497
|
+
return self._header
|
|
1498
|
+
|
|
1499
|
+
def body(self) -> QWidget:
|
|
1500
|
+
return self._body
|
|
1501
|
+
|
|
1502
|
+
def arrow(self) -> FontIcon:
|
|
1503
|
+
return self._arrow
|