QT-PyQt-PySide-Custom-Widgets-Pro 1.1.0__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.
Files changed (45) hide show
  1. custom_widgets_pro/__init__.py +73 -0
  2. custom_widgets_pro/_catalog.py +25 -0
  3. custom_widgets_pro/_cli.py +132 -0
  4. custom_widgets_pro/_license.py +419 -0
  5. custom_widgets_pro/_version.py +14 -0
  6. custom_widgets_pro/datatable/__init__.py +17 -0
  7. custom_widgets_pro/datatable/datatable_pro.py +319 -0
  8. custom_widgets_pro/datatable/export.py +143 -0
  9. custom_widgets_pro/datatable/frozen_view.py +112 -0
  10. custom_widgets_pro/datatable/grouping.py +309 -0
  11. custom_widgets_pro/datatable/pivot.py +100 -0
  12. custom_widgets_pro/datatable/provider.py +98 -0
  13. custom_widgets_pro/datatable/virtual_model.py +451 -0
  14. custom_widgets_pro/widgets/__init__.py +7 -0
  15. custom_widgets_pro/widgets/charts/QCustomBeeswarm.py +317 -0
  16. custom_widgets_pro/widgets/charts/QCustomBubbleChart.py +655 -0
  17. custom_widgets_pro/widgets/charts/QCustomCandlestickChart.py +522 -0
  18. custom_widgets_pro/widgets/charts/QCustomDivergingBarChart.py +430 -0
  19. custom_widgets_pro/widgets/charts/QCustomDotMatrix.py +266 -0
  20. custom_widgets_pro/widgets/charts/QCustomFunnelChart.py +474 -0
  21. custom_widgets_pro/widgets/charts/QCustomGanttChart.py +376 -0
  22. custom_widgets_pro/widgets/charts/QCustomHeatmap.py +555 -0
  23. custom_widgets_pro/widgets/charts/QCustomRadarChart.py +600 -0
  24. custom_widgets_pro/widgets/charts/QCustomRadialBars.py +441 -0
  25. custom_widgets_pro/widgets/charts/QCustomRadialLines.py +554 -0
  26. custom_widgets_pro/widgets/charts/QCustomRangeBarChart.py +504 -0
  27. custom_widgets_pro/widgets/charts/QCustomSankey.py +539 -0
  28. custom_widgets_pro/widgets/charts/QCustomScatterChart.py +623 -0
  29. custom_widgets_pro/widgets/charts/__init__.py +1 -0
  30. custom_widgets_pro/widgets/data/QCustomCodeEditor.py +391 -0
  31. custom_widgets_pro/widgets/data/QCustomNodeGraph.py +1030 -0
  32. custom_widgets_pro/widgets/data/QCustomRichTextEditor.py +189 -0
  33. custom_widgets_pro/widgets/data/QCustomTableToolbar.py +566 -0
  34. custom_widgets_pro/widgets/data/__init__.py +1 -0
  35. custom_widgets_pro/widgets/media/QCustomImageViewer.py +300 -0
  36. custom_widgets_pro/widgets/media/QCustomMediaGrid.py +197 -0
  37. custom_widgets_pro/widgets/media/QCustomMediaTimeline.py +607 -0
  38. custom_widgets_pro/widgets/media/QCustomVideoPlayer.py +326 -0
  39. custom_widgets_pro/widgets/media/__init__.py +1 -0
  40. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/METADATA +112 -0
  41. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/RECORD +45 -0
  42. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/WHEEL +5 -0
  43. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/entry_points.txt +2 -0
  44. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/licenses/LICENSE +91 -0
  45. qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,655 @@
1
+ ########################################################################
2
+ ## SPINN DESIGN CODE
3
+ # YOUTUBE: (SPINN TV) https://www.youtube.com/@SpinnTV
4
+ # WEBSITE: customwidgets.org
5
+ ########################################################################
6
+
7
+ ########################################################################
8
+ ## QCustomBubbleChart - an interactive packed-circle (bubble) chart.
9
+ ##
10
+ ## The sentiment / share bubble cloud: one circle per item, AREA proportional to
11
+ ## the value, coloured by category and labelled inside when big enough. Circles
12
+ ## are packed by a small deterministic force relaxation (push overlaps apart +
13
+ ## gravity to a per-category anchor when grouped), then scaled to fill the widget.
14
+ ##
15
+ ## INTERACTIVE (the reference has all of these):
16
+ ## * hover -> a CUSTOM painted tooltip card (never the OS QToolTip), plus a
17
+ ## grow animation + glow on the hovered bubble.
18
+ ## * zoom -> wheel zooms toward the cursor, a painted +/- control, drag to pan,
19
+ ## double-click to reset. zoomIn()/zoomOut()/resetView().
20
+ ## * search -> setSearchQuery(text) dims the non-matching bubbles; the painted
21
+ ## search button emits searchRequested().
22
+ ## * scale -> optional category grouping (`groupByCategory`) + axis labels.
23
+ ##
24
+ ## setItems([{label, value, category}]) / itemsJson; colours via
25
+ ## setCategoryColors({category: colour}). Signals: bubbleClicked(label),
26
+ ## searchRequested(), zoomChanged(float).
27
+ ########################################################################
28
+ import json
29
+ import math
30
+
31
+ from qtpy.QtCore import Qt, Property, Signal, QRectF, QPointF, QTimer
32
+ from qtpy.QtGui import QColor, QPainter, QPen, QBrush, QFont, QFontMetrics, QPainterPath
33
+ from qtpy.QtWidgets import QWidget, QSizePolicy
34
+
35
+
36
+ class QCustomBubbleChart(QWidget):
37
+
38
+ bubbleClicked = Signal(str)
39
+ searchRequested = Signal()
40
+ zoomChanged = Signal(float)
41
+
42
+ WIDGET_ICON = "components/icons/bubble_chart.png"
43
+ WIDGET_TOOLTIP = "An interactive packed-circle (bubble) chart"
44
+ WIDGET_MODULE = "Custom_Widgets.QCustomBubbleChart"
45
+ WIDGET_DOM_XML = """
46
+ <ui language='c++'>
47
+ <widget class='QCustomBubbleChart' name='customBubbleChart'>
48
+ <property name='geometry'><rect><x>0</x><y>0</y><width>360</width><height>320</height></rect></property>
49
+ </widget>
50
+ </ui>
51
+ """
52
+ __catalog__ = {
53
+ "name": "QCustomBubbleChart",
54
+ "props": {
55
+ "itemsJson": {"type": "string", "default": ""},
56
+ "categoriesJson": {"type": "string", "default": ""},
57
+ "padding": {"type": "float", "default": 3.0},
58
+ "showLabels": {"type": "bool", "default": True},
59
+ "minLabelRadius": {"type": "float", "default": 16.0},
60
+ "labelColor": {"type": "color", "default": "#ffffff"},
61
+ "defaultColor": {"type": "color", "default": "#8b90a0"},
62
+ "shadeVariation": {"type": "float", "default": 0.18},
63
+ "hoverGlow": {"type": "bool", "default": True},
64
+ "hoverScale": {"type": "float", "default": 1.16},
65
+ "groupByCategory": {"type": "bool", "default": False},
66
+ "zoomable": {"type": "bool", "default": True},
67
+ "showControls": {"type": "bool", "default": True},
68
+ "searchQuery": {"type": "string", "default": ""},
69
+ "tooltips": {"type": "bool", "default": True},
70
+ "tooltipBgColor": {"type": "color", "default": "#0e1016"},
71
+ "controlColor": {"type": "color", "default": "#f4f6fb"},
72
+ },
73
+ "signals": ["bubbleClicked", "searchRequested", "zoomChanged"],
74
+ # Intentionally no tokens_used: this widget resolves no token roles.
75
+ }
76
+
77
+ # Rich editors for the Designer "Custom Properties" dock (see
78
+ # DesignerTools.CustomPropertiesDock).
79
+ DESIGNER_CUSTOM_PROPS = [
80
+ {"name": "itemsJson", "kind": "str", "group": "General"},
81
+ {"name": "categoriesJson", "kind": "str", "group": "General"},
82
+ {"name": "padding", "kind": "float", "group": "General"},
83
+ {"name": "showLabels", "kind": "bool", "group": "General"},
84
+ {"name": "minLabelRadius", "kind": "float", "group": "General"},
85
+ {"name": "labelColor", "kind": "color", "group": "Colors"},
86
+ {"name": "defaultColor", "kind": "color", "group": "Colors"},
87
+ {"name": "shadeVariation", "kind": "float", "group": "General"},
88
+ {"name": "hoverGlow", "kind": "bool", "group": "General"},
89
+ {"name": "hoverScale", "kind": "float", "group": "General"},
90
+ {"name": "groupByCategory", "kind": "bool", "group": "General"},
91
+ {"name": "zoomable", "kind": "bool", "group": "General"},
92
+ {"name": "showControls", "kind": "bool", "group": "General"},
93
+ {"name": "searchQuery", "kind": "str", "group": "General"},
94
+ {"name": "tooltips", "kind": "bool", "group": "General"},
95
+ {"name": "tooltipBgColor", "kind": "color", "group": "Colors"},
96
+ {"name": "controlColor", "kind": "color", "group": "Colors"},
97
+ ]
98
+
99
+ _DEFAULT_CATS = {
100
+ "positive": "#3fb27f", "negative": "#e0607e", "neutral": "#8b9cff",
101
+ "up": "#3fb27f", "down": "#e0607e", "default": "#8b90a0",
102
+ }
103
+
104
+ def __init__(self, parent=None, items=None):
105
+ super().__init__(parent)
106
+ self.setObjectName("QCustomBubbleChart")
107
+ self._items = list(items) if items is not None else self._seed()
108
+ self._cats = dict(self._DEFAULT_CATS)
109
+ self._pad = 3.0
110
+ self._show_labels = True
111
+ self._min_label_r = 16.0
112
+ self._label_color = QColor("#ffffff")
113
+ self._default_color = QColor("#8b90a0")
114
+ self._shade = 0.18
115
+ self._hover_glow = True
116
+ self._hover_scale = 1.16
117
+ self._group = False
118
+ self._zoomable = True
119
+ self._show_controls = True
120
+ self._search = ""
121
+ self._tooltips = True
122
+ self._tip_bg = QColor("#0e1016")
123
+ self._ctrl_color = QColor("#f4f6fb")
124
+ # view state
125
+ self._zoom = 1.0
126
+ self._pan = QPointF(0.0, 0.0)
127
+ self._drag_from = None
128
+ self._dragged = False
129
+ # packed data + hit caches
130
+ self._nodes = []
131
+ self._bbox = (0.0, 0.0, 1.0, 1.0)
132
+ self._screen = [] # (label, cx, cy, r, matches) for hit-testing
133
+ self._hover = -1
134
+ self._grow = 1.0
135
+ self._hover_timer = None
136
+ self._plus_rect = QRectF()
137
+ self._minus_rect = QRectF()
138
+ self._search_rect = QRectF()
139
+ self.setMouseTracking(True)
140
+ self.setCursor(Qt.PointingHandCursor)
141
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
142
+ self.setMinimumSize(140, 140)
143
+ self._pack()
144
+
145
+ @staticmethod
146
+ def _seed():
147
+ return [
148
+ {"label": "Travel insurance", "value": 90, "category": "negative"},
149
+ {"label": "Low commission", "value": 82, "category": "positive"},
150
+ {"label": "Wait time", "value": 55, "category": "negative"},
151
+ {"label": "Resolve issue", "value": 60, "category": "positive"},
152
+ {"label": "Shipping price", "value": 48, "category": "negative"},
153
+ {"label": "Leave review", "value": 44, "category": "positive"},
154
+ {"label": "Link account", "value": 40, "category": "neutral"},
155
+ {"label": "High price", "value": 36, "category": "negative"},
156
+ {"label": "Low price", "value": 34, "category": "positive"},
157
+ {"label": "Charge fee", "value": 30, "category": "neutral"},
158
+ {"label": "Send email", "value": 26, "category": "negative"},
159
+ {"label": "Debit card", "value": 24, "category": "positive"},
160
+ {"label": "Coupon code", "value": 30, "category": "negative"},
161
+ {"label": "Buy button", "value": 18, "category": "neutral"},
162
+ {"label": "Ship item", "value": 16, "category": "negative"},
163
+ {"label": "Open card", "value": 20, "category": "positive"},
164
+ {"label": "Use app", "value": 14, "category": "neutral"},
165
+ {"label": "Post item", "value": 22, "category": "negative"},
166
+ ]
167
+
168
+ # ------------------------------------------------------------------ #
169
+ ## Data
170
+ # ------------------------------------------------------------------ #
171
+ def setItems(self, items):
172
+ self._items = [dict(it) for it in (items or [])]
173
+ self._pack()
174
+ self.update()
175
+
176
+ def setCategoryColors(self, mapping):
177
+ for k, v in (mapping or {}).items():
178
+ self._cats[str(k)] = v if isinstance(v, str) else QColor(v).name()
179
+ self.update()
180
+
181
+ def setSearchQuery(self, text):
182
+ self._search = str(text or "")
183
+ self.update()
184
+
185
+ def items(self):
186
+ return list(self._items)
187
+
188
+ def _cat_color(self, cat):
189
+ return QColor(self._cats.get(cat, self._default_color.name()))
190
+
191
+ # ------------------------------------------------------------------ #
192
+ ## View controls
193
+ # ------------------------------------------------------------------ #
194
+ def zoomIn(self):
195
+ self._zoom_to(self._zoom * 1.2, QPointF(self.width() / 2, self.height() / 2))
196
+
197
+ def zoomOut(self):
198
+ self._zoom_to(self._zoom / 1.2, QPointF(self.width() / 2, self.height() / 2))
199
+
200
+ def resetView(self):
201
+ self._zoom = 1.0
202
+ self._pan = QPointF(0.0, 0.0)
203
+ self.zoomChanged.emit(self._zoom)
204
+ self.update()
205
+
206
+ def _zoom_to(self, z, focus):
207
+ z = max(0.5, min(6.0, z))
208
+ c = QPointF(self.width() / 2.0, self.height() / 2.0)
209
+ d = focus - c
210
+ # keep the point under `focus` fixed while zooming
211
+ self._pan = d - (d - self._pan) * (z / self._zoom)
212
+ self._zoom = z
213
+ self.zoomChanged.emit(self._zoom)
214
+ self.update()
215
+
216
+ # ------------------------------------------------------------------ #
217
+ ## Packing (deterministic force relaxation, size-independent)
218
+ # ------------------------------------------------------------------ #
219
+ def _pack(self):
220
+ items = self._items
221
+ self._nodes = []
222
+ if not items:
223
+ self._bbox = (0.0, 0.0, 1.0, 1.0)
224
+ return
225
+ vals = [max(1e-6, float(it.get("value", 1))) for it in items]
226
+ vmax = max(vals)
227
+ # per-category anchors on a circle (used only when grouped)
228
+ cats = []
229
+ for it in items:
230
+ c = it.get("category", "default")
231
+ if c not in cats:
232
+ cats.append(c)
233
+ anchors = {}
234
+ if self._group and len(cats) > 1:
235
+ R = 60.0
236
+ for i, c in enumerate(cats):
237
+ a = -math.pi / 2 + 2 * math.pi * i / len(cats)
238
+ anchors[c] = (R * math.cos(a), R * math.sin(a))
239
+ golden = 2.399963229728653
240
+ nodes = []
241
+ for i, it in enumerate(items):
242
+ r = 10.0 + 34.0 * math.sqrt(vals[i] / vmax)
243
+ ang = i * golden
244
+ rad = 6.0 * math.sqrt(i + 0.5)
245
+ ax, ay = anchors.get(it.get("category", "default"), (0.0, 0.0))
246
+ nodes.append({"label": str(it.get("label", "")),
247
+ "cat": it.get("category", "default"), "val": vals[i],
248
+ "x": ax + rad * math.cos(ang), "y": ay + rad * math.sin(ang),
249
+ "r": r, "ax": ax, "ay": ay})
250
+ for _ in range(180):
251
+ for a in range(len(nodes)):
252
+ na = nodes[a]
253
+ for b in range(a + 1, len(nodes)):
254
+ nb = nodes[b]
255
+ dx = nb["x"] - na["x"]; dy = nb["y"] - na["y"]
256
+ d = math.hypot(dx, dy)
257
+ mind = na["r"] + nb["r"] + self._pad
258
+ if 1e-6 < d < mind:
259
+ push = (mind - d) / 2.0
260
+ ux, uy = dx / d, dy / d
261
+ na["x"] -= ux * push; na["y"] -= uy * push
262
+ nb["x"] += ux * push; nb["y"] += uy * push
263
+ elif d <= 1e-6:
264
+ na["x"] -= 0.5; nb["x"] += 0.5
265
+ for n in nodes: # gravity to the (category) anchor
266
+ n["x"] += (n["ax"] - n["x"]) * 0.02
267
+ n["y"] += (n["ay"] - n["y"]) * 0.02
268
+ xs0 = min(n["x"] - n["r"] for n in nodes)
269
+ ys0 = min(n["y"] - n["r"] for n in nodes)
270
+ xs1 = max(n["x"] + n["r"] for n in nodes)
271
+ ys1 = max(n["y"] + n["r"] for n in nodes)
272
+ self._nodes = nodes
273
+ self._bbox = (xs0, ys0, xs1 - xs0, ys1 - ys0)
274
+
275
+ def _base_fit(self):
276
+ m = 12.0
277
+ bx, by, bw, bh = self._bbox
278
+ bw = bw or 1.0; bh = bh or 1.0
279
+ s = min((self.width() - 2 * m) / bw, (self.height() - 2 * m) / bh)
280
+ ox = (self.width() - bw * s) / 2.0 - bx * s
281
+ oy = (self.height() - bh * s) / 2.0 - by * s
282
+ return s, ox, oy
283
+
284
+ def _to_screen(self, n):
285
+ s, ox, oy = self._base_fit()
286
+ c = QPointF(self.width() / 2.0, self.height() / 2.0)
287
+ fx = n["x"] * s + ox
288
+ fy = n["y"] * s + oy
289
+ cx = c.x() + (fx - c.x()) * self._zoom + self._pan.x()
290
+ cy = c.y() + (fy - c.y()) * self._zoom + self._pan.y()
291
+ return cx, cy, n["r"] * s * self._zoom
292
+
293
+ # ------------------------------------------------------------------ #
294
+ ## Painting
295
+ # ------------------------------------------------------------------ #
296
+ def paintEvent(self, e):
297
+ if not self._nodes:
298
+ return
299
+ p = QPainter(self)
300
+ p.setRenderHint(QPainter.Antialiasing, True)
301
+ vmax = max((n["val"] for n in self._nodes), default=1.0)
302
+ q = self._search.strip().lower()
303
+ self._screen = []
304
+ for i, n in enumerate(self._nodes):
305
+ cx, cy, r = self._to_screen(n)
306
+ matches = (not q) or (q in n["label"].lower())
307
+ self._screen.append((n["label"], cx, cy, r, matches))
308
+ if i == self._hover:
309
+ continue
310
+ self._draw_bubble(p, n, cx, cy, r, vmax, grow=1.0, glow=False,
311
+ dim=(not matches))
312
+ if 0 <= self._hover < len(self._screen):
313
+ _, cx, cy, r, matches = self._screen[self._hover]
314
+ self._draw_bubble(p, self._nodes[self._hover], cx, cy, r, vmax,
315
+ grow=self._grow, glow=self._hover_glow,
316
+ dim=(not matches))
317
+ if self._show_controls and self._zoomable:
318
+ self._paint_controls(p)
319
+ if self._tooltips and 0 <= self._hover < len(self._screen):
320
+ self._paint_tooltip(p)
321
+ p.end()
322
+
323
+ def _draw_bubble(self, p, n, cx, cy, r, vmax, grow, glow, dim):
324
+ col = self._cat_color(n["cat"])
325
+ frac = n["val"] / vmax
326
+ col = col.lighter(int(100 + self._shade * 100 * (1.0 - frac)))
327
+ if dim:
328
+ col.setAlpha(46)
329
+ rr = r * grow
330
+ if glow and grow > 1.001 and not dim:
331
+ for k in range(3, 0, -1):
332
+ t = k / 3.0
333
+ gc = QColor(col); gc.setAlphaF(0.22 * (1.1 - t))
334
+ p.setPen(Qt.NoPen); p.setBrush(QBrush(gc))
335
+ p.drawEllipse(QPointF(cx, cy), rr + t * 10.0, rr + t * 10.0)
336
+ p.setPen(Qt.NoPen)
337
+ p.setBrush(QBrush(col))
338
+ p.drawEllipse(QPointF(cx, cy), rr, rr)
339
+ if self._show_labels and rr >= self._min_label_r and n["label"] and not dim:
340
+ self._paint_label(p, n["label"], cx, cy, rr)
341
+
342
+ def _paint_label(self, p, text, cx, cy, r):
343
+ f = QFont(self.font()); f.setPointSizeF(max(7.0, r * 0.26)); f.setBold(True)
344
+ p.setFont(f)
345
+ fm = p.fontMetrics()
346
+ elided = fm.elidedText(text, Qt.ElideRight, int(r * 1.7))
347
+ p.setPen(QPen(self._label_color))
348
+ p.drawText(QRectF(cx - r, cy - fm.height() / 2.0, 2 * r, fm.height()),
349
+ Qt.AlignCenter, elided)
350
+
351
+ def _paint_controls(self, p):
352
+ w = 34.0
353
+ x = self.width() - w - 12.0
354
+ y = self.height() / 2.0 - w
355
+ self._plus_rect = QRectF(x, y, w, w)
356
+ self._minus_rect = QRectF(x, y + w, w, w)
357
+ pill = QRectF(x, y, w, 2 * w)
358
+ p.setPen(QPen(QColor(self._ctrl_color).darker(300)))
359
+ bg = QColor(self._tip_bg); bg.setAlpha(210)
360
+ p.setBrush(QBrush(bg))
361
+ p.drawRoundedRect(pill, w / 2.0, w / 2.0)
362
+ pen = QPen(self._ctrl_color, 2.0); pen.setCapStyle(Qt.RoundCap)
363
+ p.setPen(pen)
364
+ c1 = self._plus_rect.center(); c2 = self._minus_rect.center()
365
+ p.drawLine(QPointF(c1.x() - 7, c1.y()), QPointF(c1.x() + 7, c1.y()))
366
+ p.drawLine(QPointF(c1.x(), c1.y() - 7), QPointF(c1.x(), c1.y() + 7))
367
+ p.drawLine(QPointF(c2.x() - 7, c2.y()), QPointF(c2.x() + 7, c2.y()))
368
+ # search button below the zoom pill
369
+ self._search_rect = QRectF(x, y + 2 * w + 10, w, w)
370
+ p.setPen(QPen(QColor(self._ctrl_color).darker(300)))
371
+ p.setBrush(QBrush(bg))
372
+ p.drawEllipse(self._search_rect)
373
+ sc = self._search_rect.center()
374
+ pen2 = QPen(self._ctrl_color, 2.0); pen2.setCapStyle(Qt.RoundCap)
375
+ p.setPen(pen2); p.setBrush(Qt.NoBrush)
376
+ p.drawEllipse(QPointF(sc.x() - 2, sc.y() - 2), 5.5, 5.5)
377
+ p.drawLine(QPointF(sc.x() + 2.2, sc.y() + 2.2), QPointF(sc.x() + 7, sc.y() + 7))
378
+
379
+ def _paint_tooltip(self, p):
380
+ _, cx, cy, r, _ = self._screen[self._hover]
381
+ n = self._nodes[self._hover]
382
+ tf = QFont(self.font()); tf.setBold(True); tf.setPointSizeF(max(9.0, self.font().pointSizeF()))
383
+ sf = QFont(self.font()); sf.setPointSizeF(max(8.0, self.font().pointSizeF() * 0.86))
384
+ fm, fms = QFontMetrics(tf), QFontMetrics(sf)
385
+ cat = n.get("cat", "")
386
+ sub = "%g%s" % (n["val"], " · " + cat if cat and cat != "default" else "")
387
+ padx, pady, dot = 12.0, 9.0, 9.0
388
+ tw = max(fm.horizontalAdvance(n["label"]), fms.horizontalAdvance(sub))
389
+ cw = dot + 8 + tw + 2 * padx
390
+ ch = fm.height() + fms.height() + 2 * pady + 2
391
+ tx = min(max(8.0, cx - cw / 2.0), self.width() - cw - 8.0)
392
+ ty = cy - r - ch - 10.0
393
+ if ty < 6:
394
+ ty = cy + r + 10.0
395
+ card = QRectF(tx, ty, cw, ch)
396
+ path = QPainterPath(); path.addRoundedRect(card, 10, 10)
397
+ p.setPen(QPen(QColor(255, 255, 255, 28)))
398
+ p.setBrush(QBrush(self._tip_bg))
399
+ p.drawPath(path)
400
+ dcx = tx + padx + dot / 2.0
401
+ p.setPen(Qt.NoPen); p.setBrush(QBrush(self._cat_color(cat)))
402
+ p.drawEllipse(QPointF(dcx, ty + pady + fm.height() * 0.5), dot / 2.0, dot / 2.0)
403
+ text_x = tx + padx + dot + 8
404
+ p.setFont(tf); p.setPen(QPen(QColor("#ffffff")))
405
+ p.drawText(QRectF(text_x, ty + pady, tw, fm.height()),
406
+ Qt.AlignVCenter | Qt.AlignLeft, n["label"])
407
+ p.setFont(sf); p.setPen(QPen(QColor("#aab2bd")))
408
+ p.drawText(QRectF(text_x, ty + pady + fm.height() + 2, tw, fms.height()),
409
+ Qt.AlignVCenter | Qt.AlignLeft, sub)
410
+
411
+ # ------------------------------------------------------------------ #
412
+ ## Interaction
413
+ # ------------------------------------------------------------------ #
414
+ def _bubble_at(self, pos):
415
+ for i in range(len(self._screen) - 1, -1, -1):
416
+ _, cx, cy, r, _ = self._screen[i]
417
+ if math.hypot(pos.x() - cx, pos.y() - cy) <= r:
418
+ return i
419
+ return -1
420
+
421
+ def mousePressEvent(self, e):
422
+ if e.button() != Qt.LeftButton:
423
+ return super().mousePressEvent(e)
424
+ pos = e.position() if hasattr(e, "position") else QPointF(e.pos())
425
+ if self._show_controls and self._zoomable:
426
+ if self._plus_rect.contains(pos):
427
+ return self.zoomIn()
428
+ if self._minus_rect.contains(pos):
429
+ return self.zoomOut()
430
+ if self._search_rect.contains(pos):
431
+ return self.searchRequested.emit()
432
+ self._drag_from = pos
433
+ self._dragged = False
434
+ super().mousePressEvent(e)
435
+
436
+ def mouseMoveEvent(self, e):
437
+ pos = e.position() if hasattr(e, "position") else QPointF(e.pos())
438
+ if self._drag_from is not None and (e.buttons() & Qt.LeftButton):
439
+ delta = pos - self._drag_from
440
+ if self._zoomable and (self._dragged or delta.manhattanLength() > 4):
441
+ self._dragged = True
442
+ self._pan += delta
443
+ self._drag_from = pos
444
+ self.update()
445
+ return
446
+ i = self._bubble_at(pos)
447
+ if i != self._hover:
448
+ self._hover = i
449
+ self._grow = 1.0 if i >= 0 else self._grow
450
+ self._start_hover_anim()
451
+ self.update()
452
+ super().mouseMoveEvent(e)
453
+
454
+ def mouseReleaseEvent(self, e):
455
+ pos = e.position() if hasattr(e, "position") else QPointF(e.pos())
456
+ if e.button() == Qt.LeftButton and self._drag_from is not None and not self._dragged:
457
+ i = self._bubble_at(pos)
458
+ if i >= 0:
459
+ self.bubbleClicked.emit(self._screen[i][0])
460
+ self._drag_from = None
461
+ self._dragged = False
462
+ super().mouseReleaseEvent(e)
463
+
464
+ def mouseDoubleClickEvent(self, e):
465
+ if self._zoomable:
466
+ self.resetView()
467
+ super().mouseDoubleClickEvent(e)
468
+
469
+ def wheelEvent(self, e):
470
+ if not self._zoomable:
471
+ return super().wheelEvent(e)
472
+ d = e.angleDelta().y()
473
+ if d:
474
+ pos = e.position() if hasattr(e, "position") else QPointF(e.pos())
475
+ self._zoom_to(self._zoom * (1.15 if d > 0 else 1 / 1.15), pos)
476
+ e.accept()
477
+
478
+ def leaveEvent(self, e):
479
+ if self._hover != -1:
480
+ self._hover = -1
481
+ self._start_hover_anim()
482
+ self.update()
483
+ super().leaveEvent(e)
484
+
485
+ def _start_hover_anim(self):
486
+ if self._hover_timer is None:
487
+ self._hover_timer = QTimer(self)
488
+ self._hover_timer.setInterval(16)
489
+ self._hover_timer.timeout.connect(self._hover_tick)
490
+ self._hover_timer.start()
491
+
492
+ def _hover_tick(self):
493
+ target = self._hover_scale if self._hover >= 0 else 1.0
494
+ self._grow += (target - self._grow) * 0.28
495
+ if abs(self._grow - target) < 0.004:
496
+ self._grow = target
497
+ if target == 1.0:
498
+ self._hover_timer.stop()
499
+ self.update()
500
+
501
+ def resizeEvent(self, e):
502
+ self.update()
503
+ super().resizeEvent(e)
504
+
505
+ # ------------------------------------------------------------------ #
506
+ ## Designer properties
507
+ # ------------------------------------------------------------------ #
508
+ @Property(str)
509
+ def itemsJson(self):
510
+ try:
511
+ return json.dumps(self._items)
512
+ except Exception:
513
+ return "[]"
514
+
515
+ @itemsJson.setter
516
+ def itemsJson(self, text):
517
+ try:
518
+ data = json.loads(text) if str(text).strip() else []
519
+ if isinstance(data, list):
520
+ self.setItems(data)
521
+ except Exception:
522
+ pass
523
+
524
+ @Property(str)
525
+ def categoriesJson(self):
526
+ return json.dumps(self._cats)
527
+
528
+ @categoriesJson.setter
529
+ def categoriesJson(self, text):
530
+ try:
531
+ data = json.loads(text) if str(text).strip() else {}
532
+ if isinstance(data, dict):
533
+ self.setCategoryColors(data)
534
+ except Exception:
535
+ pass
536
+
537
+ @Property(float)
538
+ def padding(self):
539
+ return self._pad
540
+
541
+ @padding.setter
542
+ def padding(self, v):
543
+ self._pad = max(0.0, float(v)); self._pack(); self.update()
544
+
545
+ @Property(bool)
546
+ def showLabels(self):
547
+ return self._show_labels
548
+
549
+ @showLabels.setter
550
+ def showLabels(self, v):
551
+ self._show_labels = bool(v); self.update()
552
+
553
+ @Property(float)
554
+ def minLabelRadius(self):
555
+ return self._min_label_r
556
+
557
+ @minLabelRadius.setter
558
+ def minLabelRadius(self, v):
559
+ self._min_label_r = max(0.0, float(v)); self.update()
560
+
561
+ @Property(QColor)
562
+ def labelColor(self):
563
+ return self._label_color
564
+
565
+ @labelColor.setter
566
+ def labelColor(self, c):
567
+ self._label_color = QColor(c); self.update()
568
+
569
+ @Property(QColor)
570
+ def defaultColor(self):
571
+ return self._default_color
572
+
573
+ @defaultColor.setter
574
+ def defaultColor(self, c):
575
+ self._default_color = QColor(c); self.update()
576
+
577
+ @Property(float)
578
+ def shadeVariation(self):
579
+ return self._shade
580
+
581
+ @shadeVariation.setter
582
+ def shadeVariation(self, v):
583
+ self._shade = max(0.0, min(1.0, float(v))); self.update()
584
+
585
+ @Property(bool)
586
+ def hoverGlow(self):
587
+ return self._hover_glow
588
+
589
+ @hoverGlow.setter
590
+ def hoverGlow(self, v):
591
+ self._hover_glow = bool(v); self.update()
592
+
593
+ @Property(float)
594
+ def hoverScale(self):
595
+ return self._hover_scale
596
+
597
+ @hoverScale.setter
598
+ def hoverScale(self, v):
599
+ self._hover_scale = max(1.0, float(v))
600
+
601
+ @Property(bool)
602
+ def groupByCategory(self):
603
+ return self._group
604
+
605
+ @groupByCategory.setter
606
+ def groupByCategory(self, v):
607
+ self._group = bool(v); self._pack(); self.update()
608
+
609
+ @Property(bool)
610
+ def zoomable(self):
611
+ return self._zoomable
612
+
613
+ @zoomable.setter
614
+ def zoomable(self, v):
615
+ self._zoomable = bool(v); self.update()
616
+
617
+ @Property(bool)
618
+ def showControls(self):
619
+ return self._show_controls
620
+
621
+ @showControls.setter
622
+ def showControls(self, v):
623
+ self._show_controls = bool(v); self.update()
624
+
625
+ @Property(str)
626
+ def searchQuery(self):
627
+ return self._search
628
+
629
+ @searchQuery.setter
630
+ def searchQuery(self, v):
631
+ self.setSearchQuery(v)
632
+
633
+ @Property(bool)
634
+ def tooltips(self):
635
+ return self._tooltips
636
+
637
+ @tooltips.setter
638
+ def tooltips(self, v):
639
+ self._tooltips = bool(v); self.update()
640
+
641
+ @Property(QColor)
642
+ def tooltipBgColor(self):
643
+ return self._tip_bg
644
+
645
+ @tooltipBgColor.setter
646
+ def tooltipBgColor(self, c):
647
+ self._tip_bg = QColor(c); self.update()
648
+
649
+ @Property(QColor)
650
+ def controlColor(self):
651
+ return self._ctrl_color
652
+
653
+ @controlColor.setter
654
+ def controlColor(self, c):
655
+ self._ctrl_color = QColor(c); self.update()