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/widgets.py
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
from .QUI import *
|
|
2
|
+
from i18n import get_lang
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
__all__ = ['VScrollArea', 'HScrollArea', 'UnitInputLayout', 'ToggleSwitch', 'DevFlagCard']
|
|
7
|
+
|
|
8
|
+
class VScrollArea(QScrollArea):
|
|
9
|
+
def __init__(self, parent=None):
|
|
10
|
+
super().__init__(parent)
|
|
11
|
+
# 隐藏横向滚动条
|
|
12
|
+
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # 始终禁用水平滚动条
|
|
13
|
+
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # 按需显示垂直滚动条
|
|
14
|
+
|
|
15
|
+
class HScrollArea(QScrollArea):
|
|
16
|
+
def __init__(self, parent=None):
|
|
17
|
+
super().__init__(parent)
|
|
18
|
+
# 隐藏纵向滚动条
|
|
19
|
+
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
20
|
+
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
21
|
+
|
|
22
|
+
class UnitInputLayout(QLayout):
|
|
23
|
+
'''单位输入行布局:文本与下拉框使用真实尺寸,输入框拉伸填充空白,整行占满可用宽度'''
|
|
24
|
+
def __init__(self, parent=None):
|
|
25
|
+
super().__init__(parent)
|
|
26
|
+
self._item_list = [] # 存储所有子项
|
|
27
|
+
self._row_breaks = [] # 记录每行结束的索引位置(记录上一行最后一个项的索引)
|
|
28
|
+
self._stretch_widgets = [] # 需要在行内拉伸填充剩余空间的控件
|
|
29
|
+
self.setContentsMargins(0, 0, 0, 0)
|
|
30
|
+
|
|
31
|
+
def addItem(self, item: QLayoutItem):
|
|
32
|
+
'''添加子项到列表末尾'''
|
|
33
|
+
self._item_list.append(item)
|
|
34
|
+
|
|
35
|
+
def addWidget(self, widget: QWidget, stretch: int = 0):
|
|
36
|
+
'''
|
|
37
|
+
添加控件
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
widget: 要添加的控件
|
|
41
|
+
stretch: 大于 0 时该控件会拉伸填充行内的剩余空间
|
|
42
|
+
'''
|
|
43
|
+
super().addWidget(widget)
|
|
44
|
+
if stretch > 0:
|
|
45
|
+
self._stretch_widgets.append(widget)
|
|
46
|
+
|
|
47
|
+
def _is_stretch_item(self, item: QLayoutItem) -> bool:
|
|
48
|
+
'''判断子项是否为需要拉伸填充剩余空间的控件'''
|
|
49
|
+
widget = item.widget()
|
|
50
|
+
return widget is not None and any(widget is w for w in self._stretch_widgets)
|
|
51
|
+
|
|
52
|
+
def newRow(self):
|
|
53
|
+
'''标记换行位置'''
|
|
54
|
+
if self._item_list: # 只有在有子项时才记录换行
|
|
55
|
+
# 记录当前最后一个项的索引(从0开始)
|
|
56
|
+
last_index = len(self._item_list) - 1
|
|
57
|
+
# 如果还没有记录换行,或者当前最后一个项不在最近记录的换行中
|
|
58
|
+
if not self._row_breaks or last_index > self._row_breaks[-1]:
|
|
59
|
+
self._row_breaks.append(last_index)
|
|
60
|
+
|
|
61
|
+
def count(self):
|
|
62
|
+
'''返回子项数量'''
|
|
63
|
+
return len(self._item_list)
|
|
64
|
+
|
|
65
|
+
def itemAt(self, index):
|
|
66
|
+
'''获取指定索引的子项'''
|
|
67
|
+
if 0 <= index < len(self._item_list):
|
|
68
|
+
return self._item_list[index]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def takeAt(self, index):
|
|
72
|
+
'''移除并返回指定索引的子项'''
|
|
73
|
+
if 0 <= index < len(self._item_list):
|
|
74
|
+
item = self._item_list.pop(index)
|
|
75
|
+
|
|
76
|
+
# 不再追踪被移除子项的拉伸设置
|
|
77
|
+
widget = item.widget()
|
|
78
|
+
if widget is not None:
|
|
79
|
+
self._stretch_widgets = [w for w in self._stretch_widgets if w is not widget]
|
|
80
|
+
|
|
81
|
+
# 移除后需要更新换行标记
|
|
82
|
+
for i in range(len(self._row_breaks)):
|
|
83
|
+
if self._row_breaks[i] >= index:
|
|
84
|
+
self._row_breaks[i] -= 1
|
|
85
|
+
|
|
86
|
+
# 移除已经无效的换行标记(如果换行标记指向的索引已经不存在)
|
|
87
|
+
self._row_breaks = [r for r in self._row_breaks if r < len(self._item_list)]
|
|
88
|
+
|
|
89
|
+
return item
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def sizeHint(self):
|
|
93
|
+
'''返回布局的首选大小'''
|
|
94
|
+
if not self._item_list:
|
|
95
|
+
return QSize(0, 0)
|
|
96
|
+
|
|
97
|
+
# 计算行数和每行的尺寸
|
|
98
|
+
rows = self._get_rows()
|
|
99
|
+
|
|
100
|
+
# 计算总宽度(取最宽的行)
|
|
101
|
+
max_width = 0
|
|
102
|
+
for row_items in rows:
|
|
103
|
+
row_width = sum(item.sizeHint().width() for item in row_items)
|
|
104
|
+
if row_items: # 加上行内间距
|
|
105
|
+
row_width += self.spacing() * (len(row_items) - 1)
|
|
106
|
+
max_width = max(max_width, row_width)
|
|
107
|
+
|
|
108
|
+
# 计算总高度(每行高度之和 + 行间距)
|
|
109
|
+
total_height = 0
|
|
110
|
+
for i, row_items in enumerate(rows):
|
|
111
|
+
if row_items:
|
|
112
|
+
row_height = max(item.sizeHint().height() for item in row_items)
|
|
113
|
+
total_height += row_height
|
|
114
|
+
if i < len(rows) - 1: # 不是最后一行,添加行间距
|
|
115
|
+
total_height += self.spacing()
|
|
116
|
+
|
|
117
|
+
# 加上边距
|
|
118
|
+
margins = self.contentsMargins()
|
|
119
|
+
return QSize(
|
|
120
|
+
max_width + margins.left() + margins.right(),
|
|
121
|
+
total_height + margins.top() + margins.bottom()
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _get_rows(self):
|
|
125
|
+
'''将子项按行分组'''
|
|
126
|
+
if not self._item_list:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
rows = []
|
|
130
|
+
start_idx = 0
|
|
131
|
+
|
|
132
|
+
# 根据换行标记分割行
|
|
133
|
+
for break_idx in self._row_breaks:
|
|
134
|
+
if break_idx < len(self._item_list):
|
|
135
|
+
rows.append(self._item_list[start_idx:break_idx + 1])
|
|
136
|
+
start_idx = break_idx + 1
|
|
137
|
+
|
|
138
|
+
# 添加最后一行(如果有剩余项)
|
|
139
|
+
if start_idx < len(self._item_list):
|
|
140
|
+
rows.append(self._item_list[start_idx:])
|
|
141
|
+
|
|
142
|
+
# 如果没有任何换行标记,所有项都在一行
|
|
143
|
+
if not self._row_breaks and self._item_list:
|
|
144
|
+
rows.append(self._item_list)
|
|
145
|
+
|
|
146
|
+
return rows
|
|
147
|
+
|
|
148
|
+
def setGeometry(self, rect: QRect):
|
|
149
|
+
'''核心方法,按行排列所有子项:固定尺寸子项保持真实尺寸,可拉伸子项填充剩余空间'''
|
|
150
|
+
super().setGeometry(rect)
|
|
151
|
+
|
|
152
|
+
if not self._item_list:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# 获取可用区域(排除边距)
|
|
156
|
+
margins = self.contentsMargins()
|
|
157
|
+
available_rect = rect.adjusted(
|
|
158
|
+
margins.left(),
|
|
159
|
+
margins.top(),
|
|
160
|
+
-margins.right(),
|
|
161
|
+
-margins.bottom()
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
spacing = self.spacing()
|
|
165
|
+
y = available_rect.y()
|
|
166
|
+
|
|
167
|
+
# 遍历每一行
|
|
168
|
+
for row_items in self._get_rows():
|
|
169
|
+
if not row_items:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# 计算当前行的最大高度
|
|
173
|
+
row_height = max(item.sizeHint().height() for item in row_items)
|
|
174
|
+
|
|
175
|
+
stretch_flags = [self._is_stretch_item(item) for item in row_items]
|
|
176
|
+
stretch_count = sum(stretch_flags)
|
|
177
|
+
|
|
178
|
+
# 固定尺寸子项占用真实尺寸,剩余空间由可拉伸子项均分
|
|
179
|
+
fixed_width = sum(item.sizeHint().width() for item, is_stretch in zip(row_items, stretch_flags) if not is_stretch)
|
|
180
|
+
remaining_width = max(0, available_rect.width() - fixed_width - spacing * (len(row_items) - 1))
|
|
181
|
+
stretch_sizes = []
|
|
182
|
+
if stretch_count:
|
|
183
|
+
base_size, extra = divmod(remaining_width, stretch_count)
|
|
184
|
+
# 多余的像素分给前面的拉伸项,保证整行占满可用宽度
|
|
185
|
+
stretch_sizes = [base_size + 1] * extra + [base_size] * (stretch_count - extra)
|
|
186
|
+
|
|
187
|
+
row_x = available_rect.x()
|
|
188
|
+
for item, is_stretch in zip(row_items, stretch_flags):
|
|
189
|
+
if is_stretch:
|
|
190
|
+
item_width = stretch_sizes.pop(0)
|
|
191
|
+
else:
|
|
192
|
+
item_width = item.sizeHint().width()
|
|
193
|
+
item_height = item.sizeHint().height()
|
|
194
|
+
# 垂直居中
|
|
195
|
+
item_y = y + (row_height - item_height) // 2
|
|
196
|
+
item.setGeometry(QRect(row_x, item_y, item_width, item_height))
|
|
197
|
+
row_x += item_width + spacing
|
|
198
|
+
|
|
199
|
+
# 换行:更新y坐标,重置x坐标
|
|
200
|
+
y += row_height + spacing
|
|
201
|
+
|
|
202
|
+
def addUnitRow(self, text: str, input: QLineEdit, unit: QComboBox):
|
|
203
|
+
'''添加单位选择控件:文本与下拉框使用真实尺寸,输入框拉伸填充空白'''
|
|
204
|
+
self.newRow() # 换行
|
|
205
|
+
self.addWidget(QLabel(text + ': '))
|
|
206
|
+
self.addWidget(input, stretch=1)
|
|
207
|
+
self.addWidget(unit)
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class _Colors:
|
|
211
|
+
track: QColor
|
|
212
|
+
border: QColor
|
|
213
|
+
thumb: QColor
|
|
214
|
+
label: QColor
|
|
215
|
+
|
|
216
|
+
def copy(self) -> "_Colors":
|
|
217
|
+
return _Colors(
|
|
218
|
+
QColor(self.track), QColor(self.border),
|
|
219
|
+
QColor(self.thumb), QColor(self.label)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _mix_color(a: QColor, b: QColor, t: float) -> QColor:
|
|
223
|
+
"""在 sRGB 空间混合两个颜色(包含 alpha)。"""
|
|
224
|
+
t = max(0.0, min(1.0, float(t)))
|
|
225
|
+
return QColor.fromRgbF(
|
|
226
|
+
a.redF() + (b.redF() - a.redF()) * t,
|
|
227
|
+
a.greenF() + (b.greenF() - a.greenF()) * t,
|
|
228
|
+
a.blueF() + (b.blueF() - a.blueF()) * t,
|
|
229
|
+
a.alphaF() + (b.alphaF() - a.alphaF()) * t,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
class ToggleSwitch(QAbstractButton):
|
|
233
|
+
"""胶囊形开关,支持悬浮和拖动指示器。
|
|
234
|
+
|
|
235
|
+
禁用(``setEnabled(False)``)时:用户无法用鼠标 / 键盘切换状态,
|
|
236
|
+
但程序仍可通过 ``setChecked()`` 修改;配色由 qss 的 ``visualState`` 为
|
|
237
|
+
``disabled-off`` / ``disabled-on`` 的规则决定。
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
stateChanged = Signal(bool)
|
|
241
|
+
|
|
242
|
+
# -- 几何尺寸 -----------------------------------------------------------
|
|
243
|
+
_DEFAULT_GAP = 4.0 # 默认态:填充区边缘到指示器边缘
|
|
244
|
+
_DEFAULT_RADIUS_EXTRA = 1.0 # 默认圆形额外增加的半径
|
|
245
|
+
_HOVER_GAP = 2.0 # 悬浮态:填充区边缘到指示器边缘
|
|
246
|
+
_PRESS_STRETCH = 4.0 # 按下时向中间额外拉伸的宽度
|
|
247
|
+
_STRETCH_DURATION = 125 # 按下 / 松开拉伸动画时长(毫秒)
|
|
248
|
+
_HITBOX_PADDING_Y = 8.0 # 碰撞箱上下额外扩展
|
|
249
|
+
_LABEL_GAP = 12.0 # 文字与开关框的间距
|
|
250
|
+
_BORDER_WIDTH = 1.0
|
|
251
|
+
|
|
252
|
+
# 兜底颜色:样式表生效前使用,也用于没有加载任何样式表的场景。
|
|
253
|
+
_OFF_TRACK = QColor("#272727")
|
|
254
|
+
_OFF_BORDER = QColor("#9b9da6")
|
|
255
|
+
_OFF_THUMB = QColor("#cecece")
|
|
256
|
+
_OFF_LABEL = QColor("#ffffff")
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
parent: QWidget | None = None,
|
|
261
|
+
*,
|
|
262
|
+
state_text: tuple[str, str] | None,
|
|
263
|
+
checked: bool = False,
|
|
264
|
+
track_width: int = 40,
|
|
265
|
+
track_height: int = 20,
|
|
266
|
+
animation_duration: int = 250,
|
|
267
|
+
easing_curve: QEasingCurve | int = QEasingCurve.OutCubic,
|
|
268
|
+
) -> None:
|
|
269
|
+
super().__init__(parent)
|
|
270
|
+
|
|
271
|
+
if not isinstance(state_text, (tuple, list)) or len(state_text) != 2:
|
|
272
|
+
raise ValueError("state_text 必须是 (关文本, 开文本) 二元组")
|
|
273
|
+
state_text = (get_lang('off', default='off'), get_lang('on', default='on')) if state_text is None else state_text
|
|
274
|
+
self._off_text, self._on_text = (str(state_text[0]), str(state_text[1]))
|
|
275
|
+
|
|
276
|
+
self.setCheckable(True)
|
|
277
|
+
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
278
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
279
|
+
self.setMouseTracking(True)
|
|
280
|
+
self.setCursor(Qt.PointingHandCursor)
|
|
281
|
+
self.setAttribute(Qt.WA_Hover, True)
|
|
282
|
+
|
|
283
|
+
# ``track_width`` / ``track_height`` 表示胶囊填充区域尺寸。
|
|
284
|
+
# 1px 边框绘制在填充区外侧,因此默认 track_height=20 时
|
|
285
|
+
# 可见外框高度为 20 + 2px。
|
|
286
|
+
self._track_width = max(32, int(track_width))
|
|
287
|
+
self._track_height = max(12, int(track_height))
|
|
288
|
+
self._border_width = float(self._BORDER_WIDTH)
|
|
289
|
+
self._outer_height = self._track_height + 2.0 * self._border_width
|
|
290
|
+
self._animation_duration = max(0, int(animation_duration))
|
|
291
|
+
self._easing = QEasingCurve(easing_curve)
|
|
292
|
+
self._label_gap = self._LABEL_GAP
|
|
293
|
+
|
|
294
|
+
# 计算指示器尺寸
|
|
295
|
+
self._thumb_normal = max(
|
|
296
|
+
1.0,
|
|
297
|
+
self._track_height
|
|
298
|
+
- 2.0 * self._DEFAULT_GAP
|
|
299
|
+
+ 2.0 * self._DEFAULT_RADIUS_EXTRA,
|
|
300
|
+
)
|
|
301
|
+
self._thumb_hover = max(1.0, self._track_height - 2.0 * self._HOVER_GAP)
|
|
302
|
+
self._thumb_pressed_w = self._thumb_hover + self._PRESS_STRETCH
|
|
303
|
+
if self._track_width <= self._thumb_pressed_w:
|
|
304
|
+
self._track_width = int(math.ceil(self._thumb_pressed_w)) + 20
|
|
305
|
+
|
|
306
|
+
# 字体确定后再测量布局几何
|
|
307
|
+
self._label_width = 0.0
|
|
308
|
+
self._outer_left = 0.0
|
|
309
|
+
self._body_left = 0.0
|
|
310
|
+
self._left_center = 0.0
|
|
311
|
+
self._right_center = 0.0
|
|
312
|
+
self._center = 0.0
|
|
313
|
+
self._geometry_ready = False
|
|
314
|
+
|
|
315
|
+
# 交互状态
|
|
316
|
+
self._hovered = False
|
|
317
|
+
self._mouse_down = False
|
|
318
|
+
self._press_mouse_x = 0.0
|
|
319
|
+
self._press_center = 0.0
|
|
320
|
+
self._press_direction = 1.0 # +1:关态向右拉伸;-1:开态向左拉伸
|
|
321
|
+
self._stretch_direction = 1.0
|
|
322
|
+
self._stretch = 0.0
|
|
323
|
+
self._keyboard_focus = False
|
|
324
|
+
|
|
325
|
+
# QSS 目标颜色和实际绘制颜色(颜色过渡时使用)
|
|
326
|
+
self._target_colors = _Colors(
|
|
327
|
+
self._OFF_TRACK, self._OFF_BORDER, self._OFF_THUMB, self._OFF_LABEL
|
|
328
|
+
)
|
|
329
|
+
self._display_colors = self._target_colors.copy()
|
|
330
|
+
self._suppress_color_sync = False
|
|
331
|
+
self._repolishing = False
|
|
332
|
+
|
|
333
|
+
# 动画对象
|
|
334
|
+
self._slide_anim: QVariantAnimation | None = None
|
|
335
|
+
self._stretch_anim: QVariantAnimation | None = None
|
|
336
|
+
self._color_anim: QVariantAnimation | None = None
|
|
337
|
+
self._color_from = self._display_colors.copy()
|
|
338
|
+
self._color_to = self._display_colors.copy()
|
|
339
|
+
|
|
340
|
+
font = QFont(self.font())
|
|
341
|
+
font.setWeight(QFont.Medium)
|
|
342
|
+
self.setFont(font)
|
|
343
|
+
|
|
344
|
+
self._set_visual_state("off")
|
|
345
|
+
self.setAccessibleName("开关")
|
|
346
|
+
|
|
347
|
+
self._refresh_size()
|
|
348
|
+
self._sync_display_colours()
|
|
349
|
+
|
|
350
|
+
# 基类信号还可用于键盘和程序化触发
|
|
351
|
+
self.pressed.connect(self._update_visual_state)
|
|
352
|
+
self.released.connect(self._update_visual_state)
|
|
353
|
+
self.toggled.connect(self._on_toggled)
|
|
354
|
+
|
|
355
|
+
if checked:
|
|
356
|
+
self.setChecked(True)
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------ API
|
|
359
|
+
def set_animation_duration(self, milliseconds: int) -> None:
|
|
360
|
+
"""设置状态切换动画时长(毫秒,默认 250)。"""
|
|
361
|
+
self._animation_duration = max(0, int(milliseconds))
|
|
362
|
+
|
|
363
|
+
def animation_duration(self) -> int:
|
|
364
|
+
return self._animation_duration
|
|
365
|
+
|
|
366
|
+
def set_easing_curve(self, curve: QEasingCurve | int) -> None:
|
|
367
|
+
"""设置状态切换使用的缓动曲线。"""
|
|
368
|
+
self._easing = QEasingCurve(curve)
|
|
369
|
+
|
|
370
|
+
def easing_curve(self) -> QEasingCurve:
|
|
371
|
+
return QEasingCurve(self._easing)
|
|
372
|
+
|
|
373
|
+
def set_state_text(self, state_text: tuple[str, str]) -> None:
|
|
374
|
+
"""按 ``(关文本, 开文本)`` 设置状态文字。"""
|
|
375
|
+
if not isinstance(state_text, (tuple, list)) or len(state_text) != 2:
|
|
376
|
+
raise ValueError("state_text 必须是 (关文本, 开文本) 二元组")
|
|
377
|
+
self._off_text, self._on_text = (str(state_text[0]), str(state_text[1]))
|
|
378
|
+
self._refresh_size()
|
|
379
|
+
self.update()
|
|
380
|
+
|
|
381
|
+
def state_text(self) -> tuple[str, str]:
|
|
382
|
+
return self._off_text, self._on_text
|
|
383
|
+
|
|
384
|
+
def sizeHint(self) -> QSize:
|
|
385
|
+
metrics = QFontMetricsF(self.font())
|
|
386
|
+
label_w = math.ceil(max(
|
|
387
|
+
metrics.horizontalAdvance(self._off_text),
|
|
388
|
+
metrics.horizontalAdvance(self._on_text),
|
|
389
|
+
))
|
|
390
|
+
outer_w = self._track_width + 2.0 * self._border_width
|
|
391
|
+
width = label_w + self._label_gap + outer_w
|
|
392
|
+
height = max(self._outer_height, metrics.height()) + 2 * self._HITBOX_PADDING_Y
|
|
393
|
+
height = int(round(height))
|
|
394
|
+
# 保证轨道上下留白为整数距离。
|
|
395
|
+
if (height - int(round(self._outer_height))) % 2:
|
|
396
|
+
height += 1
|
|
397
|
+
return QSize(int(math.ceil(width)), height)
|
|
398
|
+
|
|
399
|
+
def minimumSizeHint(self) -> QSize:
|
|
400
|
+
return self.sizeHint()
|
|
401
|
+
|
|
402
|
+
# -- QSS 颜色属性 -------------------------------------------------------
|
|
403
|
+
def _set_target_colour(self, field: str, value) -> None:
|
|
404
|
+
c = QColor(value)
|
|
405
|
+
if c == getattr(self._target_colors, field):
|
|
406
|
+
return
|
|
407
|
+
setattr(self._target_colors, field, c)
|
|
408
|
+
self._on_target_colour_changed()
|
|
409
|
+
|
|
410
|
+
def _get_track_color(self) -> QColor:
|
|
411
|
+
return QColor(self._target_colors.track)
|
|
412
|
+
|
|
413
|
+
def _set_track_color(self, value) -> None:
|
|
414
|
+
self._set_target_colour("track", value)
|
|
415
|
+
|
|
416
|
+
def _get_border_color(self) -> QColor:
|
|
417
|
+
return QColor(self._target_colors.border)
|
|
418
|
+
|
|
419
|
+
def _set_border_color(self, value) -> None:
|
|
420
|
+
self._set_target_colour("border", value)
|
|
421
|
+
|
|
422
|
+
def _get_thumb_color(self) -> QColor:
|
|
423
|
+
return QColor(self._target_colors.thumb)
|
|
424
|
+
|
|
425
|
+
def _set_thumb_color(self, value) -> None:
|
|
426
|
+
self._set_target_colour("thumb", value)
|
|
427
|
+
|
|
428
|
+
def _get_label_color(self) -> QColor:
|
|
429
|
+
return QColor(self._target_colors.label)
|
|
430
|
+
|
|
431
|
+
def _set_label_color(self, value) -> None:
|
|
432
|
+
self._set_target_colour("label", value)
|
|
433
|
+
|
|
434
|
+
# 这些属性由 STYLE_SHEET 中的 qproperty-trackColor 等声明提供。
|
|
435
|
+
trackColor = Property(QColor, _get_track_color, _set_track_color)
|
|
436
|
+
borderColor = Property(QColor, _get_border_color, _set_border_color)
|
|
437
|
+
thumbColor = Property(QColor, _get_thumb_color, _set_thumb_color)
|
|
438
|
+
labelColor = Property(QColor, _get_label_color, _set_label_color)
|
|
439
|
+
|
|
440
|
+
# ------------------------------------------------------------------ Qt
|
|
441
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
442
|
+
super().changeEvent(event)
|
|
443
|
+
|
|
444
|
+
if event.type() == QEvent.EnabledChange:
|
|
445
|
+
# 禁用时清除悬浮 / 按下 / 键盘焦点状态,避免交互视觉残留。
|
|
446
|
+
self._hovered = False
|
|
447
|
+
self._mouse_down = False
|
|
448
|
+
self._keyboard_focus = False
|
|
449
|
+
self._stop_stretch_animation()
|
|
450
|
+
self._stretch = 0.0
|
|
451
|
+
self.setCursor(Qt.PointingHandCursor if self.isEnabled() else Qt.ArrowCursor)
|
|
452
|
+
self._update_visual_state()
|
|
453
|
+
self._sync_display_colours()
|
|
454
|
+
self.update()
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
if event.type() in (QEvent.FontChange, QEvent.StyleChange):
|
|
458
|
+
self._refresh_size()
|
|
459
|
+
if event.type() == QEvent.StyleChange:
|
|
460
|
+
self._sync_display_colours()
|
|
461
|
+
self.update()
|
|
462
|
+
|
|
463
|
+
def showEvent(self, event) -> None:
|
|
464
|
+
super().showEvent(event)
|
|
465
|
+
self._apply_visual_state(force=True)
|
|
466
|
+
self._refresh_size()
|
|
467
|
+
self._sync_display_colours()
|
|
468
|
+
|
|
469
|
+
def enterEvent(self, event) -> None:
|
|
470
|
+
super().enterEvent(event)
|
|
471
|
+
if not self.isEnabled():
|
|
472
|
+
return
|
|
473
|
+
if not self._hovered:
|
|
474
|
+
self._hovered = True
|
|
475
|
+
if not self._mouse_down:
|
|
476
|
+
self._stop_color_animation(snap=True)
|
|
477
|
+
self._update_visual_state()
|
|
478
|
+
self.update()
|
|
479
|
+
|
|
480
|
+
def focusInEvent(self, event) -> None:
|
|
481
|
+
super().focusInEvent(event)
|
|
482
|
+
self._keyboard_focus = event.reason() in (
|
|
483
|
+
Qt.TabFocusReason,
|
|
484
|
+
Qt.BacktabFocusReason,
|
|
485
|
+
Qt.ShortcutFocusReason,
|
|
486
|
+
)
|
|
487
|
+
self.update()
|
|
488
|
+
|
|
489
|
+
def focusOutEvent(self, event) -> None:
|
|
490
|
+
super().focusOutEvent(event)
|
|
491
|
+
self._keyboard_focus = False
|
|
492
|
+
self.update()
|
|
493
|
+
|
|
494
|
+
def leaveEvent(self, event) -> None:
|
|
495
|
+
super().leaveEvent(event)
|
|
496
|
+
if self._hovered:
|
|
497
|
+
self._hovered = False
|
|
498
|
+
if not self._mouse_down:
|
|
499
|
+
self._stop_color_animation(snap=True)
|
|
500
|
+
self._update_visual_state()
|
|
501
|
+
self.update()
|
|
502
|
+
|
|
503
|
+
def mousePressEvent(self, event) -> None:
|
|
504
|
+
if event.button() != Qt.LeftButton or not self.isEnabled():
|
|
505
|
+
event.ignore()
|
|
506
|
+
return
|
|
507
|
+
if not self.rect().contains(event.position().toPoint()):
|
|
508
|
+
event.ignore()
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
# 新的交互会终止正在运行的过渡动画。
|
|
512
|
+
self._stop_slide_animation()
|
|
513
|
+
self._stop_color_animation(snap=True)
|
|
514
|
+
|
|
515
|
+
self.setFocus(Qt.MouseFocusReason)
|
|
516
|
+
self._mouse_down = True
|
|
517
|
+
self._press_mouse_x = event.position().x()
|
|
518
|
+
self._press_direction = 1.0 if not self.isChecked() else -1.0
|
|
519
|
+
self._stretch_direction = self._press_direction
|
|
520
|
+
self._press_center = self._center
|
|
521
|
+
self._animate_stretch(1.0)
|
|
522
|
+
self.setDown(True)
|
|
523
|
+
self._update_visual_state()
|
|
524
|
+
self.update()
|
|
525
|
+
event.accept()
|
|
526
|
+
self.pressed.emit()
|
|
527
|
+
|
|
528
|
+
def mouseMoveEvent(self, event) -> None:
|
|
529
|
+
if not self._mouse_down:
|
|
530
|
+
event.ignore()
|
|
531
|
+
return
|
|
532
|
+
dx = event.position().x() - self._press_mouse_x
|
|
533
|
+
self._center = self._clamp_base_center(self._press_center + dx)
|
|
534
|
+
self.update()
|
|
535
|
+
event.accept()
|
|
536
|
+
|
|
537
|
+
def mouseReleaseEvent(self, event) -> None:
|
|
538
|
+
if (
|
|
539
|
+
event.button() != Qt.LeftButton
|
|
540
|
+
or not self._mouse_down
|
|
541
|
+
or not self.isEnabled()
|
|
542
|
+
):
|
|
543
|
+
event.ignore()
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
# 拉伸在 125 毫秒内收回,状态切换的滑动和颜色过渡则按各自时长运行。
|
|
547
|
+
self._mouse_down = False
|
|
548
|
+
self._animate_stretch(0.0)
|
|
549
|
+
self.setDown(False)
|
|
550
|
+
self.nextCheckState()
|
|
551
|
+
self._update_visual_state()
|
|
552
|
+
self.update()
|
|
553
|
+
self.released.emit()
|
|
554
|
+
self.clicked.emit()
|
|
555
|
+
event.accept()
|
|
556
|
+
|
|
557
|
+
def keyPressEvent(self, event) -> None:
|
|
558
|
+
if not self.isEnabled():
|
|
559
|
+
event.ignore()
|
|
560
|
+
return
|
|
561
|
+
# 空格 / 回车的键盘行为交给 QAbstractButton 处理。
|
|
562
|
+
if event.key() in (Qt.Key_Space, Qt.Key_Return, Qt.Key_Enter):
|
|
563
|
+
if not event.isAutoRepeat():
|
|
564
|
+
self.update()
|
|
565
|
+
super().keyPressEvent(event)
|
|
566
|
+
|
|
567
|
+
def keyReleaseEvent(self, event) -> None:
|
|
568
|
+
if not self.isEnabled():
|
|
569
|
+
event.ignore()
|
|
570
|
+
return
|
|
571
|
+
super().keyReleaseEvent(event)
|
|
572
|
+
self.update()
|
|
573
|
+
|
|
574
|
+
def hitButton(self, pos) -> bool:
|
|
575
|
+
# 整个控件矩形(含左侧文字和上下各 8px 留白)都是碰撞箱。
|
|
576
|
+
point = pos.toPoint() if hasattr(pos, "toPoint") else pos
|
|
577
|
+
return self.rect().contains(point)
|
|
578
|
+
|
|
579
|
+
# ------------------------------------------------------------- 几何计算
|
|
580
|
+
def _refresh_size(self) -> None:
|
|
581
|
+
metrics = QFontMetricsF(self.font())
|
|
582
|
+
label_w = math.ceil(max(
|
|
583
|
+
metrics.horizontalAdvance(self._off_text),
|
|
584
|
+
metrics.horizontalAdvance(self._on_text),
|
|
585
|
+
))
|
|
586
|
+
outer_w = self._track_width + 2.0 * self._border_width
|
|
587
|
+
width = label_w + self._label_gap + outer_w
|
|
588
|
+
height = max(self._outer_height, metrics.height()) + 2 * self._HITBOX_PADDING_Y
|
|
589
|
+
height = int(round(height))
|
|
590
|
+
if (height - int(round(self._outer_height))) % 2:
|
|
591
|
+
height += 1
|
|
592
|
+
hint = QSize(int(math.ceil(width)), height)
|
|
593
|
+
|
|
594
|
+
old_size = self.size()
|
|
595
|
+
old_body_left = self._body_left
|
|
596
|
+
if self.size() != hint:
|
|
597
|
+
self.setFixedSize(hint)
|
|
598
|
+
|
|
599
|
+
self._label_width = label_w
|
|
600
|
+
self._outer_left = label_w + self._label_gap
|
|
601
|
+
self._body_left = self._outer_left + self._border_width
|
|
602
|
+
self._left_center = self._body_left + self._track_height / 2.0
|
|
603
|
+
self._right_center = (
|
|
604
|
+
self._body_left + self._track_width - self._track_height / 2.0
|
|
605
|
+
)
|
|
606
|
+
self._center += self._body_left - old_body_left
|
|
607
|
+
|
|
608
|
+
geometry_changed = (
|
|
609
|
+
old_size != hint or abs(self._body_left - old_body_left) > 0.001
|
|
610
|
+
)
|
|
611
|
+
if not self._geometry_ready:
|
|
612
|
+
self._center = self._right_center if self.isChecked() else self._left_center
|
|
613
|
+
self._geometry_ready = True
|
|
614
|
+
elif (
|
|
615
|
+
geometry_changed
|
|
616
|
+
and self._slide_anim is None
|
|
617
|
+
and self._stretch_anim is None
|
|
618
|
+
and not self._mouse_down
|
|
619
|
+
and self._stretch <= 0.001
|
|
620
|
+
):
|
|
621
|
+
self._center = self._right_center if self.isChecked() else self._left_center
|
|
622
|
+
|
|
623
|
+
def _outer_rect(self) -> QRectF:
|
|
624
|
+
y = (self.height() - self._outer_height) / 2.0
|
|
625
|
+
return QRectF(
|
|
626
|
+
self._outer_left,
|
|
627
|
+
y,
|
|
628
|
+
self._track_width + 2.0 * self._border_width,
|
|
629
|
+
self._outer_height,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
def _body_rect(self) -> QRectF:
|
|
633
|
+
return self._outer_rect().adjusted(
|
|
634
|
+
self._border_width,
|
|
635
|
+
self._border_width,
|
|
636
|
+
-self._border_width,
|
|
637
|
+
-self._border_width,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
def _thumb_metrics(self) -> tuple[float, float, float]:
|
|
641
|
+
"""返回当前拉伸量对应的(宽、高、X 偏移)。"""
|
|
642
|
+
stretched = self._hovered or self._mouse_down
|
|
643
|
+
rest = self._thumb_hover if stretched else self._thumb_normal
|
|
644
|
+
s = max(0.0, min(1.0, self._stretch))
|
|
645
|
+
width = rest + (self._thumb_pressed_w - rest) * s
|
|
646
|
+
height = rest + (self._thumb_hover - rest) * s
|
|
647
|
+
offset = (
|
|
648
|
+
self._stretch_direction
|
|
649
|
+
* (self._thumb_pressed_w - self._thumb_hover)
|
|
650
|
+
/ 2.0
|
|
651
|
+
* s
|
|
652
|
+
)
|
|
653
|
+
return width, height, offset
|
|
654
|
+
|
|
655
|
+
def _thumb_rect(self) -> QRectF:
|
|
656
|
+
track = self._body_rect()
|
|
657
|
+
width, height, offset = self._thumb_metrics()
|
|
658
|
+
center = self._center + offset
|
|
659
|
+
low = track.left() + width / 2.0
|
|
660
|
+
high = track.right() - width / 2.0
|
|
661
|
+
if high < low:
|
|
662
|
+
high = low
|
|
663
|
+
center = max(low, min(high, center))
|
|
664
|
+
return QRectF(
|
|
665
|
+
center - width / 2.0,
|
|
666
|
+
track.center().y() - height / 2.0,
|
|
667
|
+
width,
|
|
668
|
+
height,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def _clamp_base_center(self, center: float) -> float:
|
|
672
|
+
track = self._body_rect()
|
|
673
|
+
width, _height, offset = self._thumb_metrics()
|
|
674
|
+
low = track.left() + width / 2.0 - offset
|
|
675
|
+
high = track.right() - width / 2.0 - offset
|
|
676
|
+
if high < low:
|
|
677
|
+
high = low
|
|
678
|
+
return max(low, min(high, float(center)))
|
|
679
|
+
|
|
680
|
+
def _target_center(self, checked: bool) -> float:
|
|
681
|
+
return self._right_center if checked else self._left_center
|
|
682
|
+
|
|
683
|
+
# -------------------------------------------------------------- 颜色状态
|
|
684
|
+
def _set_visual_state(self, state: str) -> None:
|
|
685
|
+
if self.property("visualState") != state:
|
|
686
|
+
self.setProperty("visualState", state)
|
|
687
|
+
self._repolish()
|
|
688
|
+
|
|
689
|
+
def _apply_visual_state(self, force: bool = False) -> None:
|
|
690
|
+
if not self.isEnabled():
|
|
691
|
+
# 禁用态与开 / 关组合,颜色由 qss 中 disabled-on / disabled-off 选择器提供
|
|
692
|
+
state = "disabled-on" if self.isChecked() else "disabled-off"
|
|
693
|
+
elif self._mouse_down:
|
|
694
|
+
state = "on-pressed" if self.isChecked() else "off-pressed"
|
|
695
|
+
elif self._hovered:
|
|
696
|
+
state = "on-hover" if self.isChecked() else "off-hover"
|
|
697
|
+
else:
|
|
698
|
+
state = "on" if self.isChecked() else "off"
|
|
699
|
+
if force and self.property("visualState") == state:
|
|
700
|
+
self._repolish()
|
|
701
|
+
else:
|
|
702
|
+
self._set_visual_state(state)
|
|
703
|
+
|
|
704
|
+
def _update_visual_state(self) -> None:
|
|
705
|
+
self._apply_visual_state()
|
|
706
|
+
self.update()
|
|
707
|
+
|
|
708
|
+
def _repolish(self) -> None:
|
|
709
|
+
if self._repolishing:
|
|
710
|
+
return
|
|
711
|
+
self._repolishing = True
|
|
712
|
+
try:
|
|
713
|
+
self.style().unpolish(self)
|
|
714
|
+
self.style().polish(self)
|
|
715
|
+
finally:
|
|
716
|
+
self._repolishing = False
|
|
717
|
+
|
|
718
|
+
def _on_target_colour_changed(self) -> None:
|
|
719
|
+
# 由样式表 qproperty-* setter 在重新 polish 时调用。
|
|
720
|
+
if self._suppress_color_sync:
|
|
721
|
+
return
|
|
722
|
+
if (
|
|
723
|
+
self._color_anim is not None
|
|
724
|
+
and self._color_anim.state() == QVariantAnimation.Running
|
|
725
|
+
):
|
|
726
|
+
# 运行期间的外部配色变化不应让动画继续追逐旧目标色。
|
|
727
|
+
self._color_to = self._target_colors.copy()
|
|
728
|
+
return
|
|
729
|
+
self._display_colors = self._target_colors.copy()
|
|
730
|
+
self.update()
|
|
731
|
+
|
|
732
|
+
def _sync_display_colours(self) -> None:
|
|
733
|
+
if self._suppress_color_sync:
|
|
734
|
+
return
|
|
735
|
+
if self._color_anim is None:
|
|
736
|
+
self._display_colors = self._target_colors.copy()
|
|
737
|
+
self.update()
|
|
738
|
+
|
|
739
|
+
# ------------------------------------------------------------ 滑动动画
|
|
740
|
+
def _on_toggled(self, checked: bool) -> None:
|
|
741
|
+
self._refresh_size()
|
|
742
|
+
target_center = self._target_center(checked)
|
|
743
|
+
|
|
744
|
+
# 先通知外部状态已经变化,再启动滑动 / 颜色动画,这样在槽函数里
|
|
745
|
+
# 读到的 isChecked() 一定是新状态。
|
|
746
|
+
self.stateChanged.emit(bool(checked))
|
|
747
|
+
|
|
748
|
+
# 隐藏状态下直接切换,避免第一次显示时动画已经播放了一部分。
|
|
749
|
+
if not self.isVisible():
|
|
750
|
+
self._stop_slide_animation()
|
|
751
|
+
self._stop_stretch_animation()
|
|
752
|
+
self._stop_color_animation(snap=False)
|
|
753
|
+
self._stretch = 0.0
|
|
754
|
+
self._suppress_color_sync = True
|
|
755
|
+
try:
|
|
756
|
+
self._apply_visual_state(force=True)
|
|
757
|
+
self._center = target_center
|
|
758
|
+
self._display_colors = self._target_colors.copy()
|
|
759
|
+
finally:
|
|
760
|
+
self._suppress_color_sync = False
|
|
761
|
+
self.update()
|
|
762
|
+
return
|
|
763
|
+
|
|
764
|
+
start_colors = self._display_colors.copy()
|
|
765
|
+
start_center = self._center
|
|
766
|
+
|
|
767
|
+
# 在切换新状态时暂时冻结颜色同步,以便捕获新的目标配色,
|
|
768
|
+
# 然后再向目标颜色插值。
|
|
769
|
+
self._suppress_color_sync = True
|
|
770
|
+
try:
|
|
771
|
+
self._stop_slide_animation()
|
|
772
|
+
self._stop_color_animation(snap=False)
|
|
773
|
+
self._apply_visual_state(force=True)
|
|
774
|
+
target_colors = self._target_colors.copy()
|
|
775
|
+
finally:
|
|
776
|
+
self._suppress_color_sync = False
|
|
777
|
+
|
|
778
|
+
if self._animation_duration == 0:
|
|
779
|
+
self._center = target_center
|
|
780
|
+
self._display_colors = target_colors
|
|
781
|
+
self.update()
|
|
782
|
+
return
|
|
783
|
+
|
|
784
|
+
if abs(target_center - start_center) > 0.01:
|
|
785
|
+
anim = QVariantAnimation(self)
|
|
786
|
+
anim.setStartValue(0.0)
|
|
787
|
+
anim.setEndValue(1.0)
|
|
788
|
+
anim.setDuration(self._animation_duration)
|
|
789
|
+
anim.setEasingCurve(self._easing)
|
|
790
|
+
anim.valueChanged.connect(
|
|
791
|
+
lambda value: self._on_slide_tick(value, start_center, target_center)
|
|
792
|
+
)
|
|
793
|
+
anim.finished.connect(
|
|
794
|
+
lambda: self._on_slide_finished(target_center)
|
|
795
|
+
)
|
|
796
|
+
self._slide_anim = anim
|
|
797
|
+
anim.start()
|
|
798
|
+
else:
|
|
799
|
+
self._center = target_center
|
|
800
|
+
self.update()
|
|
801
|
+
|
|
802
|
+
self._start_color_animation(start_colors, target_colors)
|
|
803
|
+
|
|
804
|
+
def _on_slide_tick(self, value, start_center: float, target_center: float) -> None:
|
|
805
|
+
center = start_center + (target_center - start_center) * float(value)
|
|
806
|
+
# OutBack 等缓动曲线可能过冲,这里仍把指示器限制在胶囊内。
|
|
807
|
+
self._center = self._clamp_base_center(center)
|
|
808
|
+
self.update()
|
|
809
|
+
|
|
810
|
+
def _on_slide_finished(self, target_center: float) -> None:
|
|
811
|
+
self._center = target_center
|
|
812
|
+
self._slide_anim = None
|
|
813
|
+
self.update()
|
|
814
|
+
|
|
815
|
+
def _stop_slide_animation(self) -> None:
|
|
816
|
+
if self._slide_anim is not None:
|
|
817
|
+
anim = self._slide_anim
|
|
818
|
+
self._slide_anim = None
|
|
819
|
+
anim.stop()
|
|
820
|
+
anim.deleteLater()
|
|
821
|
+
|
|
822
|
+
# -- 按下 / 松开拉伸 ---------------------------------------------------
|
|
823
|
+
def _animate_stretch(self, target: float) -> None:
|
|
824
|
+
target = max(0.0, min(1.0, float(target)))
|
|
825
|
+
self._stop_stretch_animation()
|
|
826
|
+
start = self._stretch
|
|
827
|
+
if abs(target - start) <= 0.001:
|
|
828
|
+
self._stretch = target
|
|
829
|
+
self.update()
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
duration = self._STRETCH_DURATION
|
|
833
|
+
if duration <= 0:
|
|
834
|
+
self._stretch = target
|
|
835
|
+
self.update()
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
anim = QVariantAnimation(self)
|
|
839
|
+
anim.setStartValue(0.0)
|
|
840
|
+
anim.setEndValue(1.0)
|
|
841
|
+
anim.setDuration(duration)
|
|
842
|
+
anim.setEasingCurve(QEasingCurve.OutCubic)
|
|
843
|
+
anim.valueChanged.connect(
|
|
844
|
+
lambda value: self._on_stretch_tick(value, start, target)
|
|
845
|
+
)
|
|
846
|
+
anim.finished.connect(lambda: self._on_stretch_finished(target))
|
|
847
|
+
self._stretch_anim = anim
|
|
848
|
+
anim.start()
|
|
849
|
+
|
|
850
|
+
def _on_stretch_tick(self, value, start: float, target: float) -> None:
|
|
851
|
+
self._stretch = start + (target - start) * float(value)
|
|
852
|
+
self.update()
|
|
853
|
+
|
|
854
|
+
def _on_stretch_finished(self, target: float) -> None:
|
|
855
|
+
self._stretch = target
|
|
856
|
+
self._stretch_anim = None
|
|
857
|
+
self.update()
|
|
858
|
+
|
|
859
|
+
def _stop_stretch_animation(self) -> None:
|
|
860
|
+
if self._stretch_anim is not None:
|
|
861
|
+
anim = self._stretch_anim
|
|
862
|
+
self._stretch_anim = None
|
|
863
|
+
anim.stop()
|
|
864
|
+
anim.deleteLater()
|
|
865
|
+
|
|
866
|
+
def _start_color_animation(self, start: _Colors, target: _Colors) -> None:
|
|
867
|
+
self._stop_color_animation(snap=False)
|
|
868
|
+
self._color_from = start.copy()
|
|
869
|
+
self._color_to = target.copy()
|
|
870
|
+
if self._animation_duration == 0:
|
|
871
|
+
self._display_colors = target.copy()
|
|
872
|
+
self.update()
|
|
873
|
+
return
|
|
874
|
+
|
|
875
|
+
anim = QVariantAnimation(self)
|
|
876
|
+
anim.setStartValue(0.0)
|
|
877
|
+
anim.setEndValue(1.0)
|
|
878
|
+
anim.setDuration(self._animation_duration)
|
|
879
|
+
anim.setEasingCurve(self._easing)
|
|
880
|
+
anim.valueChanged.connect(self._on_color_tick)
|
|
881
|
+
anim.finished.connect(self._on_color_finished)
|
|
882
|
+
self._color_anim = anim
|
|
883
|
+
anim.start()
|
|
884
|
+
|
|
885
|
+
def _on_color_tick(self, value) -> None:
|
|
886
|
+
t = float(value)
|
|
887
|
+
self._display_colors = _Colors(
|
|
888
|
+
_mix_color(self._color_from.track, self._color_to.track, t),
|
|
889
|
+
_mix_color(self._color_from.border, self._color_to.border, t),
|
|
890
|
+
_mix_color(self._color_from.thumb, self._color_to.thumb, t),
|
|
891
|
+
_mix_color(self._color_from.label, self._color_to.label, t),
|
|
892
|
+
)
|
|
893
|
+
self.update()
|
|
894
|
+
|
|
895
|
+
def _on_color_finished(self) -> None:
|
|
896
|
+
self._display_colors = self._color_to.copy()
|
|
897
|
+
self._color_anim = None
|
|
898
|
+
self.update()
|
|
899
|
+
|
|
900
|
+
def _stop_color_animation(self, snap: bool) -> None:
|
|
901
|
+
if self._color_anim is not None:
|
|
902
|
+
anim = self._color_anim
|
|
903
|
+
self._color_anim = None
|
|
904
|
+
anim.stop()
|
|
905
|
+
anim.deleteLater()
|
|
906
|
+
if snap:
|
|
907
|
+
self._sync_display_colours()
|
|
908
|
+
|
|
909
|
+
# --------------------------------------------------------------- 绘制
|
|
910
|
+
def paintEvent(self, event) -> None:
|
|
911
|
+
painter = QPainter(self)
|
|
912
|
+
painter.setRenderHint(QPainter.Antialiasing, True)
|
|
913
|
+
painter.setRenderHint(QPainter.TextAntialiasing, True)
|
|
914
|
+
|
|
915
|
+
outer = self._outer_rect()
|
|
916
|
+
body = self._body_rect()
|
|
917
|
+
border = self._display_colors.border
|
|
918
|
+
|
|
919
|
+
# 1px 边框位于 20px 填充区域外侧。
|
|
920
|
+
if border.alpha() > 0:
|
|
921
|
+
outer_path = QPainterPath()
|
|
922
|
+
outer_path.addRoundedRect(
|
|
923
|
+
outer, outer.height() / 2.0, outer.height() / 2.0
|
|
924
|
+
)
|
|
925
|
+
painter.fillPath(outer_path, border)
|
|
926
|
+
|
|
927
|
+
body_path = QPainterPath()
|
|
928
|
+
body_path.addRoundedRect(body, body.height() / 2.0, body.height() / 2.0)
|
|
929
|
+
painter.fillPath(body_path, self._display_colors.track)
|
|
930
|
+
|
|
931
|
+
# 圆形指示器。
|
|
932
|
+
painter.setPen(Qt.NoPen)
|
|
933
|
+
painter.setBrush(self._display_colors.thumb)
|
|
934
|
+
painter.drawEllipse(self._thumb_rect())
|
|
935
|
+
|
|
936
|
+
# 左侧文字。
|
|
937
|
+
painter.setFont(self.font())
|
|
938
|
+
painter.setPen(self._display_colors.label)
|
|
939
|
+
label_rect = QRectF(
|
|
940
|
+
0.0,
|
|
941
|
+
0.0,
|
|
942
|
+
max(0.0, self._label_width),
|
|
943
|
+
float(self.height()),
|
|
944
|
+
)
|
|
945
|
+
text = self._on_text if self.isChecked() else self._off_text
|
|
946
|
+
painter.drawText(
|
|
947
|
+
label_rect,
|
|
948
|
+
Qt.AlignLeft | Qt.AlignVCenter,
|
|
949
|
+
text,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
if self.hasFocus() and self._keyboard_focus:
|
|
953
|
+
focus_color = QColor(self._display_colors.label)
|
|
954
|
+
focus_color.setAlpha(150)
|
|
955
|
+
focus_pen = QPen(focus_color)
|
|
956
|
+
focus_pen.setWidthF(1.0)
|
|
957
|
+
painter.setPen(focus_pen)
|
|
958
|
+
painter.setBrush(Qt.NoBrush)
|
|
959
|
+
focus_path = QPainterPath()
|
|
960
|
+
fr = body.adjusted(1.5, 1.5, -1.5, -1.5)
|
|
961
|
+
focus_path.addRoundedRect(fr, fr.height() / 2.0, fr.height() / 2.0)
|
|
962
|
+
painter.drawPath(focus_path)
|