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/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)