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,1030 @@
1
+ ########################################################################
2
+ ## SPINN DESIGN CODE
3
+ # YOUTUBE: (SPINN TV) https://www.youtube.com/@SpinnTV
4
+ # WEBSITE: customwidgets.org
5
+ ########################################################################
6
+
7
+ ########################################################################
8
+ ## QCustomNodeGraph - a node-based visual editor canvas.
9
+ ##
10
+ ## An infinite, pan/zoom canvas with a dotted-grid backdrop that hosts
11
+ ## draggable NODE cards. Each node has a titled header (accent dot), a body of
12
+ ## painted content (freeform text, label/value rows, an image thumbnail, or
13
+ ## chips) and typed input/output PORTS (the socket dots). Drag from an output
14
+ ## port to an input port to wire nodes together with a curved bezier CABLE.
15
+ ##
16
+ ## Everything is painted with QPainter (crisp at any zoom, no assets) and every
17
+ ## colour is a qproperty, so the whole graph recolours on a theme switch. It is
18
+ ## data-driven: build a graph declaratively with addNode()/addEdge() or
19
+ ## setGraph({...}); read interaction back through nodeMoved / nodeSelected /
20
+ ## connectionMade / nodeClicked / canvasClicked.
21
+ ##
22
+ ## This is the headline "flow builder / pipeline / AI graph" surface — the
23
+ ## SETTINGS/IDEAS/REFERENCES/AI-MODELS canvas in node-based creative tools.
24
+ ########################################################################
25
+ import os
26
+ import math
27
+
28
+ from qtpy.QtCore import Qt, Property, QRectF, QPointF, QByteArray, QSize, Signal, QTimer
29
+ from qtpy.QtGui import (QColor, QPainter, QBrush, QPen, QFont, QPainterPath,
30
+ QLinearGradient, QPixmap)
31
+ from qtpy.QtWidgets import QWidget, QSizePolicy
32
+
33
+ try:
34
+ from qtpy.QtSvg import QSvgRenderer
35
+ except Exception:
36
+ QSvgRenderer = None
37
+
38
+
39
+ class _Node:
40
+ """Plain data holder for one node card (world coordinates)."""
41
+
42
+ __slots__ = ("id", "title", "x", "y", "w", "h", "accent", "text",
43
+ "rows", "image", "chips", "inputs", "outputs")
44
+
45
+ def __init__(self, nid, title="Node", x=0.0, y=0.0, w=200.0, h=120.0,
46
+ accent="#f2a63b", text="", rows=None, image="", chips=None,
47
+ inputs=None, outputs=None):
48
+ self.id = nid
49
+ self.title = title
50
+ self.x = float(x)
51
+ self.y = float(y)
52
+ self.w = float(w)
53
+ self.h = float(h)
54
+ self.accent = QColor(accent)
55
+ self.text = text or ""
56
+ self.rows = list(rows or []) # [{"label":..,"value":..,"dot":color}]
57
+ self.image = image or "" # path to a thumbnail
58
+ self.chips = list(chips or []) # ["Gemini", "Seedance2"]
59
+ self.inputs = list(inputs or []) # port labels
60
+ self.outputs = list(outputs or [])
61
+
62
+
63
+ class _Edge:
64
+ __slots__ = ("src", "src_port", "dst", "dst_port", "color")
65
+
66
+ def __init__(self, src, src_port, dst, dst_port, color=None):
67
+ self.src = src
68
+ self.src_port = int(src_port)
69
+ self.dst = dst
70
+ self.dst_port = int(dst_port)
71
+ self.color = QColor(color) if color else None
72
+
73
+
74
+ class QCustomNodeGraph(QWidget):
75
+
76
+ WIDGET_ICON = "components/icons/account_tree.png"
77
+ WIDGET_TOOLTIP = "A node-based visual editor canvas (pan/zoom, draggable nodes, bezier wires)"
78
+ WIDGET_MODULE = "Custom_Widgets.QCustomNodeGraph"
79
+ WIDGET_DOM_XML = """
80
+ <ui language='c++'>
81
+ <widget class='QCustomNodeGraph' name='customNodeGraph'>
82
+ <property name='geometry'><rect><x>0</x><y>0</y><width>640</width><height>420</height></rect></property>
83
+ </widget>
84
+ </ui>
85
+ """
86
+ __catalog__ = {
87
+ "name": "QCustomNodeGraph",
88
+ "props": {"bgColor": {"type": "color", "default": "#12141c"},
89
+ "gridColor": {"type": "color", "default": "#26ffffff"},
90
+ "gridSpacing": {"type": "int", "default": 26},
91
+ "nodeColor": {"type": "color", "default": "#1b1e2a"},
92
+ "nodeHeaderColor": {"type": "color", "default": "#232634"},
93
+ "nodeBorderColor": {"type": "color", "default": "#33ffffff"},
94
+ "textColor": {"type": "color", "default": "#e7e9f3"},
95
+ "mutedColor": {"type": "color", "default": "#8b90a6"},
96
+ "portColor": {"type": "color", "default": "#f2a63b"},
97
+ "edgeColor": {"type": "color", "default": "#c98a3a"},
98
+ "selectedColor": {"type": "color", "default": "#6c7bff"},
99
+ "cornerRadius": {"type": "int", "default": 14},
100
+ "animated": {"type": "bool", "default": True}},
101
+ "signals": ["nodeMoved", "nodeSelected", "nodeClicked",
102
+ "connectionMade", "canvasClicked", "rowClicked"],
103
+ # Intentionally no tokens_used: this widget resolves no token roles.
104
+ }
105
+ # Every configurable property is exposed to Qt Designer (the @Property below
106
+ # auto-appears in the property editor; these give the Custom-Properties dock
107
+ # its typed editors, grouped).
108
+ DESIGNER_CUSTOM_PROPS = [
109
+ {"name": "bgColor", "kind": "color", "group": "Canvas"},
110
+ {"name": "gridColor", "kind": "color", "group": "Canvas"},
111
+ {"name": "gridSpacing", "kind": "int", "group": "Canvas"},
112
+ {"name": "nodeColor", "kind": "color", "group": "Node"},
113
+ {"name": "nodeHeaderColor", "kind": "color", "group": "Node"},
114
+ {"name": "nodeBorderColor", "kind": "color", "group": "Node"},
115
+ {"name": "textColor", "kind": "color", "group": "Node"},
116
+ {"name": "mutedColor", "kind": "color", "group": "Node"},
117
+ {"name": "cornerRadius", "kind": "int", "group": "Node"},
118
+ {"name": "portColor", "kind": "color", "group": "Wiring"},
119
+ {"name": "edgeColor", "kind": "color", "group": "Wiring"},
120
+ {"name": "selectedColor", "kind": "color", "group": "Wiring"},
121
+ {"name": "animated", "kind": "bool", "group": "Canvas"},
122
+ ]
123
+
124
+ nodeMoved = Signal(str)
125
+ nodeSelected = Signal(str)
126
+ nodeClicked = Signal(str)
127
+ connectionMade = Signal(str, int, str, int)
128
+ connectionRemoved = Signal(str, int, str, int)
129
+ canvasClicked = Signal()
130
+ rowClicked = Signal(str, int) # (node_id, row_index) — a settings row tap
131
+ nodeRemoved = Signal(str)
132
+ editRequested = Signal(str) # the pen (edit) button on a node was clicked
133
+
134
+ def __init__(self, parent=None):
135
+ super().__init__(parent)
136
+ self.setObjectName("QCustomNodeGraph")
137
+ self.setMouseTracking(True)
138
+ self.setFocusPolicy(Qt.StrongFocus) # so Delete/Backspace reach us
139
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
140
+ self.setMinimumSize(240, 180)
141
+
142
+ # colours
143
+ self._bg = QColor("#12141c")
144
+ self._grid = QColor(255, 255, 255, 38)
145
+ self._grid_spacing = 26
146
+ self._node = QColor("#1b1e2a")
147
+ self._node_header = QColor("#232634")
148
+ self._node_border = QColor(255, 255, 255, 51)
149
+ self._text = QColor("#e7e9f3")
150
+ self._muted = QColor("#8b90a6")
151
+ self._port = QColor("#f2a63b")
152
+ self._edge = QColor("#c98a3a")
153
+ self._selected = QColor("#6c7bff")
154
+ self._radius = 14
155
+
156
+ # model
157
+ self._nodes = [] # list[_Node]
158
+ self._edges = [] # list[_Edge]
159
+ self._auto = 0 # auto id counter
160
+ self._pix_cache = {}
161
+ self._edit_pm = None # pre-rendered header button icons
162
+ self._x_pm = None
163
+ self._rebuild_glyphs()
164
+
165
+ # view transform (world -> screen: p*scale + offset)
166
+ self._scale = 1.0
167
+ self._offset = QPointF(0.0, 0.0)
168
+
169
+ # interaction state
170
+ self._sel = None # selected node id
171
+ self._sel_edge = None # selected edge index (for disconnect)
172
+ self._drag_node = None # node being dragged
173
+ self._drag_dx = 0.0
174
+ self._drag_dy = 0.0
175
+ self._panning = False
176
+ self._pan_start = QPointF()
177
+ self._pan_off0 = QPointF()
178
+ self._connect_from = None # (node_id, port_index) while wiring
179
+ self._cursor = QPointF()
180
+ self._press_node = None # node id pressed (for click-vs-drag)
181
+ self._press_row = None # row index under the press, if any
182
+ self._did_move = False # became a drag?
183
+ self._hover = None # node id currently hovered
184
+
185
+ # animation: a phase drives a bright pulse flowing along each cable, and
186
+ # hovered/selected nodes get an animated glow (animated content).
187
+ self._phase = 0.0
188
+ self._animated = True
189
+ self._anim = QTimer(self)
190
+ self._anim.setInterval(40)
191
+ self._anim.timeout.connect(self._tick)
192
+ self._anim.start()
193
+
194
+ def _tick(self):
195
+ if not self._animated or not self.isVisible():
196
+ return
197
+ self._phase = (self._phase + 0.012) % 1.0
198
+ if self._edges or self._hover or self._sel:
199
+ self.update()
200
+
201
+ # ------------------------------------------------------------------ #
202
+ ## Public data API
203
+ # ------------------------------------------------------------------ #
204
+ def addNode(self, title="Node", x=0.0, y=0.0, w=200.0, h=120.0,
205
+ accent="#f2a63b", text="", rows=None, image="", chips=None,
206
+ inputs=None, outputs=None, nid=None):
207
+ if nid is None:
208
+ self._auto += 1
209
+ nid = "node%d" % self._auto
210
+ node = _Node(nid, title, x, y, w, h, accent, text, rows, image,
211
+ chips, inputs, outputs)
212
+ self._nodes.append(node)
213
+ self.update()
214
+ return nid
215
+
216
+ def addEdge(self, src, src_port, dst, dst_port, color=None):
217
+ self._edges.append(_Edge(src, src_port, dst, dst_port, color))
218
+ self.update()
219
+
220
+ def clear(self):
221
+ self._nodes = []
222
+ self._edges = []
223
+ self._sel = None
224
+ self._auto = 0
225
+ self.update()
226
+
227
+ def setGraph(self, data):
228
+ """data = {"nodes":[{...}], "edges":[{"src","srcPort","dst","dstPort","color"}]}."""
229
+ self.clear()
230
+ for n in (data or {}).get("nodes", []):
231
+ self.addNode(**n)
232
+ for e in (data or {}).get("edges", []):
233
+ self.addEdge(e.get("src"), e.get("srcPort", 0),
234
+ e.get("dst"), e.get("dstPort", 0), e.get("color"))
235
+
236
+ def nodeById(self, nid):
237
+ for n in self._nodes:
238
+ if n.id == nid:
239
+ return n
240
+ return None
241
+
242
+ def setNodePosition(self, nid, x, y):
243
+ n = self.nodeById(nid)
244
+ if n:
245
+ n.x, n.y = float(x), float(y)
246
+ self.update()
247
+
248
+ def nodeRows(self, nid):
249
+ n = self.nodeById(nid)
250
+ return list(n.rows) if n else []
251
+
252
+ def setRowValue(self, nid, idx, value):
253
+ n = self.nodeById(nid)
254
+ if n and 0 <= idx < len(n.rows):
255
+ n.rows[idx]["value"] = str(value)
256
+ self.update()
257
+
258
+ # -- edit -------------------------------------------------------------- #
259
+ def nodeTitle(self, nid):
260
+ n = self.nodeById(nid)
261
+ return n.title if n else ""
262
+
263
+ def setNodeTitle(self, nid, title):
264
+ n = self.nodeById(nid)
265
+ if n:
266
+ n.title = str(title)
267
+ self.update()
268
+
269
+ def nodeText(self, nid):
270
+ n = self.nodeById(nid)
271
+ return n.text if n else ""
272
+
273
+ def setNodeText(self, nid, text):
274
+ n = self.nodeById(nid)
275
+ if n:
276
+ n.text = str(text)
277
+ self.update()
278
+
279
+ def nodeAccent(self, nid):
280
+ n = self.nodeById(nid)
281
+ return QColor(n.accent) if n else QColor()
282
+
283
+ def setNodeAccent(self, nid, color):
284
+ """Recolour a node (its header dot + underline, chips, glow)."""
285
+ n = self.nodeById(nid)
286
+ if n:
287
+ n.accent = QColor(color)
288
+ self.update()
289
+
290
+ # -- delete / disconnect ---------------------------------------------- #
291
+ def removeNode(self, nid):
292
+ """Delete a node and every cable attached to it."""
293
+ n = self.nodeById(nid)
294
+ if n is None:
295
+ return
296
+ self._edges = [e for e in self._edges
297
+ if e.src != nid and e.dst != nid]
298
+ self._nodes.remove(n)
299
+ if self._sel == nid:
300
+ self._sel = None
301
+ if self._hover == nid:
302
+ self._hover = None
303
+ self._sel_edge = None
304
+ self.nodeRemoved.emit(nid)
305
+ self.update()
306
+
307
+ def removeEdgeAt(self, index):
308
+ """Disconnect (remove) the edge at `index`."""
309
+ if 0 <= index < len(self._edges):
310
+ e = self._edges.pop(index)
311
+ if self._sel_edge == index:
312
+ self._sel_edge = None
313
+ self.connectionRemoved.emit(e.src, e.src_port, e.dst, e.dst_port)
314
+ self.update()
315
+
316
+ def removeEdge(self, src, src_port, dst, dst_port):
317
+ for i, e in enumerate(self._edges):
318
+ if (e.src == src and e.src_port == src_port
319
+ and e.dst == dst and e.dst_port == dst_port):
320
+ self.removeEdgeAt(i)
321
+ return
322
+
323
+ def disconnectNode(self, nid):
324
+ """Remove every cable attached to a node (keeps the node)."""
325
+ kept, dropped = [], []
326
+ for e in self._edges:
327
+ (dropped if e.src == nid or e.dst == nid else kept).append(e)
328
+ self._edges = kept
329
+ for e in dropped:
330
+ self.connectionRemoved.emit(e.src, e.src_port, e.dst, e.dst_port)
331
+ if dropped:
332
+ self._sel_edge = None
333
+ self.update()
334
+
335
+ def fitToView(self, margin=40):
336
+ if not self._nodes:
337
+ return
338
+ xs0 = min(n.x for n in self._nodes)
339
+ ys0 = min(n.y for n in self._nodes)
340
+ xs1 = max(n.x + n.w for n in self._nodes)
341
+ ys1 = max(n.y + n.h for n in self._nodes)
342
+ bw, bh = max(1.0, xs1 - xs0), max(1.0, ys1 - ys0)
343
+ sx = (self.width() - 2 * margin) / bw
344
+ sy = (self.height() - 2 * margin) / bh
345
+ self._scale = max(0.25, min(2.0, min(sx, sy)))
346
+ self._offset = QPointF(
347
+ (self.width() - bw * self._scale) / 2.0 - xs0 * self._scale,
348
+ (self.height() - bh * self._scale) / 2.0 - ys0 * self._scale)
349
+ self.update()
350
+
351
+ # ------------------------------------------------------------------ #
352
+ ## Geometry helpers
353
+ # ------------------------------------------------------------------ #
354
+ def _w2s(self, x, y):
355
+ return QPointF(x * self._scale + self._offset.x(),
356
+ y * self._scale + self._offset.y())
357
+
358
+ def _s2w(self, pt):
359
+ return QPointF((pt.x() - self._offset.x()) / self._scale,
360
+ (pt.y() - self._offset.y()) / self._scale)
361
+
362
+ def _node_rect_screen(self, n):
363
+ tl = self._w2s(n.x, n.y)
364
+ return QRectF(tl.x(), tl.y(), n.w * self._scale, n.h * self._scale)
365
+
366
+ def _port_pos(self, n, is_input, idx):
367
+ """World-space centre of a port dot."""
368
+ names = n.inputs if is_input else n.outputs
369
+ count = max(1, len(names))
370
+ header = 34.0
371
+ avail = n.h - header
372
+ gy = n.y + header + avail * (idx + 0.5) / count
373
+ gx = n.x if is_input else n.x + n.w
374
+ return QPointF(gx, gy)
375
+
376
+ def _hit_port(self, screen_pt):
377
+ """Return (node_id, is_input, idx) if a port is under the screen point."""
378
+ r = 9.0
379
+ for n in reversed(self._nodes):
380
+ for is_in, names in ((True, n.inputs), (False, n.outputs)):
381
+ for i in range(len(names)):
382
+ wp = self._port_pos(n, is_in, i)
383
+ sp = self._w2s(wp.x(), wp.y())
384
+ if (sp.x() - screen_pt.x()) ** 2 + (sp.y() - screen_pt.y()) ** 2 <= r * r:
385
+ return (n.id, is_in, i)
386
+ return None
387
+
388
+ def _hit_node(self, screen_pt):
389
+ for n in reversed(self._nodes):
390
+ if self._node_rect_screen(n).contains(screen_pt):
391
+ return n
392
+ return None
393
+
394
+ def _close_btn_rect(self, n):
395
+ """Screen rect of the node's header × (delete) button."""
396
+ rect = self._node_rect_screen(n)
397
+ sc = self._scale
398
+ s = 15.0 * sc
399
+ return QRectF(rect.right() - s - 9 * sc, rect.y() + (34.0 * sc - s) / 2, s, s)
400
+
401
+ def _edit_btn_rect(self, n):
402
+ """Screen rect of the node's header pen (edit) button, left of the ×."""
403
+ cb = self._close_btn_rect(n)
404
+ s = cb.width()
405
+ return QRectF(cb.left() - s - 5 * self._scale, cb.top(), s, s)
406
+
407
+ def _rebuild_glyphs(self):
408
+ """Pre-render the header button icons OUTSIDE paint (recolour_icon opens
409
+ its own QPainter, which corrupts the widget painter if called mid-paint)."""
410
+ from Custom_Widgets.Utils import recolor_icon
411
+ self._edit_pm = recolor_icon("edit-2", self._muted, 40)
412
+ self._x_pm = recolor_icon("x", self._muted, 40)
413
+
414
+ def _edge_at(self, screen_pt, tol=7.0):
415
+ """Index of the cable near the screen point, or None."""
416
+ for i, edge in enumerate(self._edges):
417
+ ns = self.nodeById(edge.src)
418
+ nd = self.nodeById(edge.dst)
419
+ if ns is None or nd is None:
420
+ continue
421
+ aw = self._port_pos(ns, False, edge.src_port)
422
+ bw = self._port_pos(nd, True, edge.dst_port)
423
+ path = self._cable_path(self._w2s(aw.x(), aw.y()),
424
+ self._w2s(bw.x(), bw.y()))
425
+ for k in range(0, 41):
426
+ pt = path.pointAtPercent(k / 40.0)
427
+ if (pt.x() - screen_pt.x()) ** 2 + (pt.y() - screen_pt.y()) ** 2 <= tol * tol:
428
+ return i
429
+ return None
430
+
431
+ def _row_at(self, node, screen_pt):
432
+ """Index of the label/value row under the point, or None (rows nodes)."""
433
+ if not node.rows:
434
+ return None
435
+ rect = self._node_rect_screen(node)
436
+ sc = self._scale
437
+ by = rect.y() + 34.0 * sc + 8 * sc
438
+ rh = 30.0 * sc
439
+ if screen_pt.x() < rect.left() or screen_pt.x() > rect.right():
440
+ return None
441
+ for i in range(len(node.rows)):
442
+ ry = by + i * rh
443
+ if ry <= screen_pt.y() <= ry + rh:
444
+ return i
445
+ return None
446
+
447
+ # ------------------------------------------------------------------ #
448
+ ## Painting
449
+ # ------------------------------------------------------------------ #
450
+ def paintEvent(self, e):
451
+ p = QPainter(self)
452
+ p.setRenderHint(QPainter.Antialiasing, True)
453
+ p.fillRect(self.rect(), self._bg)
454
+ self._paint_grid(p)
455
+ for i, edge in enumerate(self._edges):
456
+ self._paint_edge(p, edge, i)
457
+ if self._connect_from is not None:
458
+ self._paint_pending_edge(p)
459
+ for n in self._nodes:
460
+ self._paint_node(p, n)
461
+ p.end()
462
+
463
+ def _paint_grid(self, p):
464
+ step = self._grid_spacing * self._scale
465
+ if step < 6:
466
+ return
467
+ p.setPen(Qt.NoPen)
468
+ p.setBrush(self._grid)
469
+ ox = self._offset.x() % step
470
+ oy = self._offset.y() % step
471
+ r = 1.3
472
+ y = oy
473
+ while y < self.height():
474
+ x = ox
475
+ while x < self.width():
476
+ p.drawEllipse(QPointF(x, y), r, r)
477
+ x += step
478
+ y += step
479
+
480
+ def _cable_path(self, a, b):
481
+ dx = max(40.0, abs(b.x() - a.x()) * 0.5)
482
+ path = QPainterPath(a)
483
+ path.cubicTo(QPointF(a.x() + dx, a.y()),
484
+ QPointF(b.x() - dx, b.y()), b)
485
+ return path
486
+
487
+ def _paint_edge(self, p, edge, index=-1):
488
+ ns = self.nodeById(edge.src)
489
+ nd = self.nodeById(edge.dst)
490
+ if ns is None or nd is None:
491
+ return
492
+ aw = self._port_pos(ns, False, edge.src_port)
493
+ bw = self._port_pos(nd, True, edge.dst_port)
494
+ a = self._w2s(aw.x(), aw.y())
495
+ b = self._w2s(bw.x(), bw.y())
496
+ col = edge.color or self._edge
497
+ selected = (index == self._sel_edge)
498
+ path = self._cable_path(a, b)
499
+ if selected: # selection halo (click a cable to select)
500
+ hp = QPen(self._selected, max(4.0, 6.0 * self._scale))
501
+ hp.setCapStyle(Qt.RoundCap)
502
+ p.setPen(hp)
503
+ p.setBrush(Qt.NoBrush)
504
+ p.drawPath(path)
505
+ pen = QPen(col, max(1.6, 2.2 * self._scale))
506
+ pen.setCapStyle(Qt.RoundCap)
507
+ p.setPen(pen)
508
+ p.setBrush(Qt.NoBrush)
509
+ p.drawPath(path)
510
+ # a bright pulse flowing along the cable (animated "energy")
511
+ if self._animated:
512
+ off = (self._phase + 0.13 * (hash(edge.src) % 7)) % 1.0
513
+ pt = path.pointAtPercent(off)
514
+ glow = QColor(col).lighter(170)
515
+ p.setPen(Qt.NoPen)
516
+ p.setBrush(QColor(glow.red(), glow.green(), glow.blue(), 80))
517
+ p.drawEllipse(pt, 5 * self._scale, 5 * self._scale)
518
+ p.setBrush(glow)
519
+ p.drawEllipse(pt, 2.3 * self._scale, 2.3 * self._scale)
520
+
521
+ def _paint_pending_edge(self, p):
522
+ nid, is_in, idx = self._connect_from
523
+ n = self.nodeById(nid)
524
+ if n is None:
525
+ return
526
+ aw = self._port_pos(n, is_in, idx)
527
+ a = self._w2s(aw.x(), aw.y())
528
+ b = self._cursor
529
+ pen = QPen(self._port, 2.0, Qt.DashLine)
530
+ pen.setCapStyle(Qt.RoundCap)
531
+ p.setPen(pen)
532
+ p.setBrush(Qt.NoBrush)
533
+ if is_in:
534
+ a, b = b, a
535
+ p.drawPath(self._cable_path(a, b))
536
+
537
+ def _paint_node(self, p, n):
538
+ rect = self._node_rect_screen(n)
539
+ r = self._radius * self._scale
540
+ selected = (n.id == self._sel)
541
+
542
+ # animated glow ring on the hovered / selected node
543
+ if self._animated and (selected or n.id == self._hover):
544
+ pulse = 0.5 + 0.5 * math.sin(self._phase * 2 * math.pi)
545
+ g = self._selected if selected else self._port
546
+ p.setPen(Qt.NoPen)
547
+ p.setBrush(QColor(g.red(), g.green(), g.blue(), int(34 + 46 * pulse)))
548
+ p.drawRoundedRect(rect.adjusted(-5, -5, 5, 6), r + 5, r + 5)
549
+
550
+ # drop shadow
551
+ sh = QColor(0, 0, 0, 90)
552
+ p.setPen(Qt.NoPen)
553
+ p.setBrush(sh)
554
+ p.drawRoundedRect(rect.adjusted(0, 3, 0, 5), r, r)
555
+
556
+ # body
557
+ p.setBrush(self._node)
558
+ pen = QPen(self._selected if selected else self._node_border,
559
+ 2.0 if selected else 1.0)
560
+ p.setPen(pen)
561
+ p.drawRoundedRect(rect, r, r)
562
+
563
+ # header strip
564
+ hh = 34.0 * self._scale
565
+ header = QRectF(rect.x(), rect.y(), rect.width(), hh)
566
+ hp = QPainterPath()
567
+ hp.addRoundedRect(header, r, r)
568
+ hp.addRect(QRectF(rect.x(), rect.y() + hh - r, rect.width(), r))
569
+ p.setPen(Qt.NoPen)
570
+ p.setBrush(self._node_header)
571
+ p.drawPath(hp.simplified())
572
+
573
+ sc = self._scale
574
+ # accent underline — makes the node's (layer's) colour clearly visible
575
+ p.setBrush(n.accent)
576
+ p.drawRect(QRectF(rect.x() + r, rect.y() + hh - 2.0 * sc,
577
+ rect.width() - 2 * r, 2.0 * sc))
578
+
579
+ # accent dot + title
580
+ p.setBrush(n.accent)
581
+ p.drawEllipse(QPointF(rect.x() + 15 * sc, rect.y() + hh / 2), 4 * sc, 4 * sc)
582
+ f = QFont(self.font())
583
+ f.setPointSizeF(max(6.0, 8.5 * sc))
584
+ f.setBold(True)
585
+ f.setCapitalization(QFont.AllUppercase)
586
+ f.setLetterSpacing(QFont.PercentageSpacing, 108)
587
+ p.setFont(f)
588
+ p.setPen(QPen(self._muted))
589
+ tw = rect.width() - 40 * sc
590
+ if selected or n.id == self._hover:
591
+ tw -= 40 * sc # leave room for pen + × buttons
592
+ p.drawText(QRectF(rect.x() + 26 * sc, rect.y(), tw, hh),
593
+ Qt.AlignVCenter | Qt.AlignLeft, n.title)
594
+
595
+ # pen (edit) + delete (×) header buttons — both rendered feather icons at
596
+ # the same size so they read consistently (shown on hover / select)
597
+ if selected or n.id == self._hover:
598
+ for brect, pm in ((self._edit_btn_rect(n), self._edit_pm),
599
+ (self._close_btn_rect(n), self._x_pm)):
600
+ if pm is None or pm.isNull():
601
+ continue
602
+ inset = brect.width() * 0.14
603
+ ir = brect.adjusted(inset, inset, -inset, -inset)
604
+ p.drawPixmap(ir, pm, QRectF(0, 0, pm.width(), pm.height()))
605
+
606
+ # body content
607
+ p.setClipRect(rect)
608
+ by = rect.y() + hh + 8 * sc
609
+ bx = rect.x() + 14 * sc
610
+ bw = rect.width() - 28 * sc
611
+
612
+ if n.image:
613
+ self._paint_node_image(p, n, QRectF(bx, by, bw, rect.bottom() - by - 12 * sc))
614
+ elif n.text:
615
+ tf = QFont(self.font())
616
+ tf.setPointSizeF(max(6.0, 9.0 * sc))
617
+ p.setFont(tf)
618
+ p.setPen(QPen(self._text))
619
+ p.drawText(QRectF(bx, by, bw, rect.bottom() - by - 8 * sc),
620
+ Qt.AlignTop | Qt.TextWordWrap, n.text)
621
+ elif n.rows:
622
+ self._paint_node_rows(p, n, rect, bx, by, bw, sc)
623
+ elif n.chips:
624
+ self._paint_node_chips(p, n, bx, by, bw, sc)
625
+ p.setClipping(False)
626
+
627
+ # ports
628
+ self._paint_ports(p, n, sc)
629
+
630
+ def _paint_node_rows(self, p, n, rect, bx, by, bw, sc):
631
+ rh = 30.0 * sc
632
+ f = QFont(self.font())
633
+ f.setPointSizeF(max(6.0, 8.5 * sc))
634
+ for i, row in enumerate(n.rows):
635
+ ry = by + i * rh
636
+ if ry > rect.bottom() - 6 * sc:
637
+ break
638
+ dot = row.get("dot")
639
+ tx = bx
640
+ if dot:
641
+ p.setPen(Qt.NoPen)
642
+ p.setBrush(QColor(dot))
643
+ p.drawEllipse(QPointF(bx + 5 * sc, ry + rh / 2 - 2 * sc), 4 * sc, 4 * sc)
644
+ tx = bx + 18 * sc
645
+ p.setFont(f)
646
+ p.setPen(QPen(self._muted))
647
+ p.drawText(QRectF(tx, ry, bw * 0.5, rh - 6 * sc),
648
+ Qt.AlignVCenter | Qt.AlignLeft, str(row.get("label", "")))
649
+ val = str(row.get("value", ""))
650
+ if val:
651
+ # value pill on the right
652
+ vf = QFont(f)
653
+ fm_w = p.fontMetrics().horizontalAdvance(val) + 22 * sc
654
+ pill = QRectF(rect.right() - 14 * sc - fm_w, ry + 2 * sc,
655
+ fm_w, rh - 10 * sc)
656
+ p.setPen(Qt.NoPen)
657
+ p.setBrush(QColor(255, 255, 255, 18))
658
+ p.drawRoundedRect(pill, pill.height() / 2, pill.height() / 2)
659
+ p.setFont(vf)
660
+ p.setPen(QPen(self._text))
661
+ p.drawText(pill, Qt.AlignCenter, val)
662
+
663
+ def _paint_node_chips(self, p, n, bx, by, bw, sc):
664
+ f = QFont(self.font())
665
+ f.setPointSizeF(max(6.0, 8.5 * sc))
666
+ f.setBold(True)
667
+ p.setFont(f)
668
+ x = bx
669
+ y = by
670
+ for label in n.chips:
671
+ w = p.fontMetrics().horizontalAdvance(label) + 26 * sc
672
+ h = 26 * sc
673
+ if x + w > bx + bw:
674
+ x = bx
675
+ y += h + 8 * sc
676
+ chip = QRectF(x, y, w, h)
677
+ p.setPen(QPen(n.accent, 1.4))
678
+ p.setBrush(QColor(n.accent.red(), n.accent.green(), n.accent.blue(), 30))
679
+ p.drawRoundedRect(chip, h / 2, h / 2)
680
+ p.setPen(QPen(self._text))
681
+ p.drawText(chip, Qt.AlignCenter, label)
682
+ x += w + 8 * sc
683
+
684
+ def _paint_node_image(self, p, n, box):
685
+ pm = self._image_pixmap(n.image)
686
+ radius = 10 * self._scale
687
+ path = QPainterPath()
688
+ path.addRoundedRect(box, radius, radius)
689
+ p.save()
690
+ p.setClipPath(path)
691
+ if pm is not None and not pm.isNull():
692
+ scaled = pm.scaled(box.size().toSize(), Qt.KeepAspectRatioByExpanding,
693
+ Qt.SmoothTransformation)
694
+ sx = (scaled.width() - box.width()) / 2
695
+ sy = (scaled.height() - box.height()) / 2
696
+ p.drawPixmap(box, scaled, QRectF(sx, sy, box.width(), box.height()))
697
+ else:
698
+ grad = QLinearGradient(box.topLeft(), box.bottomRight())
699
+ grad.setColorAt(0.0, QColor("#2a2f45"))
700
+ grad.setColorAt(1.0, QColor("#1a1d29"))
701
+ p.fillRect(box, QBrush(grad))
702
+ p.restore()
703
+ p.setPen(QPen(self._node_border, 1.0))
704
+ p.setBrush(Qt.NoBrush)
705
+ p.drawPath(path)
706
+
707
+ def _paint_ports(self, p, n, sc):
708
+ rr = 4.5 * sc
709
+ for is_in, names in ((True, n.inputs), (False, n.outputs)):
710
+ for i in range(len(names)):
711
+ wp = self._port_pos(n, is_in, i)
712
+ sp = self._w2s(wp.x(), wp.y())
713
+ p.setPen(QPen(self._bg, 2.0))
714
+ p.setBrush(self._port)
715
+ p.drawEllipse(sp, rr, rr)
716
+
717
+ def _image_pixmap(self, path):
718
+ if path in self._pix_cache:
719
+ return self._pix_cache[path]
720
+ pm = None
721
+ if path and os.path.exists(path):
722
+ if path.lower().endswith(".svg") and QSvgRenderer is not None:
723
+ pm = QPixmap(400, 400)
724
+ pm.fill(QColor(0, 0, 0, 0))
725
+ rnd = QSvgRenderer(path)
726
+ pr = QPainter(pm)
727
+ rnd.render(pr)
728
+ pr.end()
729
+ else:
730
+ pm = QPixmap(path)
731
+ self._pix_cache[path] = pm
732
+ return pm
733
+
734
+ # ------------------------------------------------------------------ #
735
+ ## Interaction
736
+ # ------------------------------------------------------------------ #
737
+ def mousePressEvent(self, e):
738
+ pt = QPointF(e.position()) if hasattr(e, "position") else QPointF(e.pos())
739
+ self._cursor = pt
740
+ if e.button() == Qt.LeftButton:
741
+ # the pen (edit) + × (delete) header buttons on a hovered/selected node
742
+ for nn in reversed(self._nodes):
743
+ if nn.id != self._sel and nn.id != self._hover:
744
+ continue
745
+ if self._edit_btn_rect(nn).contains(pt):
746
+ self._sel = nn.id
747
+ self.editRequested.emit(nn.id)
748
+ self.update()
749
+ return
750
+ if self._close_btn_rect(nn).contains(pt):
751
+ self.removeNode(nn.id)
752
+ return
753
+ hit = self._hit_port(pt)
754
+ if hit is not None:
755
+ self._connect_from = hit
756
+ self.update()
757
+ return
758
+ n = self._hit_node(pt)
759
+ if n is not None:
760
+ # raise to top + select + start drag
761
+ self._nodes.remove(n)
762
+ self._nodes.append(n)
763
+ self._sel = n.id
764
+ self._sel_edge = None
765
+ self._drag_node = n
766
+ wp = self._s2w(pt)
767
+ self._drag_dx = wp.x() - n.x
768
+ self._drag_dy = wp.y() - n.y
769
+ self._press_node = n.id
770
+ self._press_row = self._row_at(n, pt)
771
+ self._did_move = False
772
+ self.nodeSelected.emit(n.id)
773
+ self.nodeClicked.emit(n.id)
774
+ self.update()
775
+ return
776
+ # a cable? select it (so it can be disconnected with Delete)
777
+ ei = self._edge_at(pt)
778
+ if ei is not None:
779
+ self._sel_edge = ei
780
+ self._sel = None
781
+ self.update()
782
+ return
783
+ # empty canvas -> pan
784
+ self._sel = None
785
+ self._sel_edge = None
786
+ self._panning = True
787
+ self._pan_start = pt
788
+ self._pan_off0 = QPointF(self._offset)
789
+ self.canvasClicked.emit()
790
+ self.setCursor(Qt.ClosedHandCursor)
791
+ self.update()
792
+ super().mousePressEvent(e)
793
+
794
+ def keyPressEvent(self, e):
795
+ if e.key() in (Qt.Key_Delete, Qt.Key_Backspace):
796
+ if self._sel is not None:
797
+ self.removeNode(self._sel)
798
+ return
799
+ if self._sel_edge is not None:
800
+ self.removeEdgeAt(self._sel_edge)
801
+ return
802
+ super().keyPressEvent(e)
803
+
804
+ def contextMenuEvent(self, e):
805
+ from qtpy.QtWidgets import QMenu
806
+ pt = QPointF(e.pos())
807
+ menu = QMenu(self)
808
+ n = self._hit_node(pt)
809
+ ei = self._edge_at(pt)
810
+ if n is not None:
811
+ act = menu.addAction("Delete node")
812
+ act.triggered.connect(lambda: self.removeNode(n.id))
813
+ elif ei is not None:
814
+ act = menu.addAction("Disconnect")
815
+ act.triggered.connect(lambda: self.removeEdgeAt(ei))
816
+ else:
817
+ return
818
+ menu.exec_(e.globalPos()) if hasattr(menu, "exec_") else menu.exec(e.globalPos())
819
+
820
+ def mouseMoveEvent(self, e):
821
+ pt = QPointF(e.position()) if hasattr(e, "position") else QPointF(e.pos())
822
+ self._cursor = pt
823
+ if self._drag_node is not None:
824
+ wp = self._s2w(pt)
825
+ nx = wp.x() - self._drag_dx
826
+ ny = wp.y() - self._drag_dy
827
+ if abs(nx - self._drag_node.x) > 2 or abs(ny - self._drag_node.y) > 2:
828
+ self._did_move = True
829
+ self._drag_node.x = nx
830
+ self._drag_node.y = ny
831
+ self.update()
832
+ return
833
+ if self._panning:
834
+ d = pt - self._pan_start
835
+ self._offset = self._pan_off0 + d
836
+ self.update()
837
+ return
838
+ if self._connect_from is not None:
839
+ self.update()
840
+ return
841
+ # hover cursor feedback over ports + track hovered node for the glow
842
+ hn = self._hit_node(pt)
843
+ new_hover = hn.id if hn is not None else None
844
+ if new_hover != self._hover:
845
+ self._hover = new_hover
846
+ self.update()
847
+ if self._hit_port(pt) is not None:
848
+ self.setCursor(Qt.CrossCursor)
849
+ elif hn is not None:
850
+ self.setCursor(Qt.OpenHandCursor)
851
+ else:
852
+ self.setCursor(Qt.ArrowCursor)
853
+ super().mouseMoveEvent(e)
854
+
855
+ def leaveEvent(self, e):
856
+ if self._hover is not None:
857
+ self._hover = None
858
+ self.update()
859
+ super().leaveEvent(e)
860
+
861
+ def mouseReleaseEvent(self, e):
862
+ pt = QPointF(e.position()) if hasattr(e, "position") else QPointF(e.pos())
863
+ if self._drag_node is not None:
864
+ if self._did_move:
865
+ self.nodeMoved.emit(self._drag_node.id)
866
+ elif self._press_row is not None:
867
+ self.rowClicked.emit(self._press_node, self._press_row)
868
+ self._drag_node = None
869
+ self._press_row = None
870
+ if self._panning:
871
+ self._panning = False
872
+ self.setCursor(Qt.ArrowCursor)
873
+ if self._connect_from is not None:
874
+ target = self._hit_port(pt)
875
+ if target is not None:
876
+ (n0, in0, i0) = self._connect_from
877
+ (n1, in1, i1) = target
878
+ # connect an output to an input (either drag direction)
879
+ if in0 != in1 and n0 != n1:
880
+ if in0: # started on an input -> the other is the source
881
+ src, sp, dst, dp = n1, i1, n0, i0
882
+ else:
883
+ src, sp, dst, dp = n0, i0, n1, i1
884
+ self.addEdge(src, sp, dst, dp)
885
+ self.connectionMade.emit(src, sp, dst, dp)
886
+ self._connect_from = None
887
+ self.update()
888
+ super().mouseReleaseEvent(e)
889
+
890
+ def wheelEvent(self, e):
891
+ delta = e.angleDelta().y()
892
+ if delta == 0:
893
+ return
894
+ factor = 1.12 if delta > 0 else 1.0 / 1.12
895
+ new_scale = max(0.3, min(2.5, self._scale * factor))
896
+ # zoom about the cursor
897
+ pt = QPointF(e.position()) if hasattr(e, "position") else QPointF(e.pos())
898
+ before = self._s2w(pt)
899
+ self._scale = new_scale
900
+ after = self._w2s(before.x(), before.y())
901
+ self._offset += (pt - after)
902
+ self.update()
903
+
904
+ def sizeHint(self):
905
+ return QSize(640, 420)
906
+
907
+ # ------------------------------------------------------------------ #
908
+ ## Designer properties
909
+ # ------------------------------------------------------------------ #
910
+ @Property(QColor)
911
+ def bgColor(self):
912
+ return self._bg
913
+
914
+ @bgColor.setter
915
+ def bgColor(self, c):
916
+ self._bg = QColor(c)
917
+ self.update()
918
+
919
+ @Property(QColor)
920
+ def gridColor(self):
921
+ return self._grid
922
+
923
+ @gridColor.setter
924
+ def gridColor(self, c):
925
+ self._grid = QColor(c)
926
+ self.update()
927
+
928
+ @Property(int)
929
+ def gridSpacing(self):
930
+ return self._grid_spacing
931
+
932
+ @gridSpacing.setter
933
+ def gridSpacing(self, v):
934
+ self._grid_spacing = max(6, int(v))
935
+ self.update()
936
+
937
+ @Property(QColor)
938
+ def nodeColor(self):
939
+ return self._node
940
+
941
+ @nodeColor.setter
942
+ def nodeColor(self, c):
943
+ self._node = QColor(c)
944
+ self.update()
945
+
946
+ @Property(QColor)
947
+ def nodeHeaderColor(self):
948
+ return self._node_header
949
+
950
+ @nodeHeaderColor.setter
951
+ def nodeHeaderColor(self, c):
952
+ self._node_header = QColor(c)
953
+ self.update()
954
+
955
+ @Property(QColor)
956
+ def nodeBorderColor(self):
957
+ return self._node_border
958
+
959
+ @nodeBorderColor.setter
960
+ def nodeBorderColor(self, c):
961
+ self._node_border = QColor(c)
962
+ self.update()
963
+
964
+ @Property(QColor)
965
+ def textColor(self):
966
+ return self._text
967
+
968
+ @textColor.setter
969
+ def textColor(self, c):
970
+ self._text = QColor(c)
971
+ self.update()
972
+
973
+ @Property(QColor)
974
+ def mutedColor(self):
975
+ return self._muted
976
+
977
+ @mutedColor.setter
978
+ def mutedColor(self, c):
979
+ self._muted = QColor(c)
980
+ self._rebuild_glyphs()
981
+ self.update()
982
+
983
+ @Property(QColor)
984
+ def portColor(self):
985
+ return self._port
986
+
987
+ @portColor.setter
988
+ def portColor(self, c):
989
+ self._port = QColor(c)
990
+ self.update()
991
+
992
+ @Property(QColor)
993
+ def edgeColor(self):
994
+ return self._edge
995
+
996
+ @edgeColor.setter
997
+ def edgeColor(self, c):
998
+ self._edge = QColor(c)
999
+ self.update()
1000
+
1001
+ @Property(QColor)
1002
+ def selectedColor(self):
1003
+ return self._selected
1004
+
1005
+ @selectedColor.setter
1006
+ def selectedColor(self, c):
1007
+ self._selected = QColor(c)
1008
+ self.update()
1009
+
1010
+ @Property(int)
1011
+ def cornerRadius(self):
1012
+ return self._radius
1013
+
1014
+ @cornerRadius.setter
1015
+ def cornerRadius(self, v):
1016
+ self._radius = max(0, int(v))
1017
+ self.update()
1018
+
1019
+ @Property(bool)
1020
+ def animated(self):
1021
+ return self._animated
1022
+
1023
+ @animated.setter
1024
+ def animated(self, v):
1025
+ self._animated = bool(v)
1026
+ if self._animated:
1027
+ self._anim.start()
1028
+ else:
1029
+ self._anim.stop()
1030
+ self.update()