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.
- custom_widgets_pro/__init__.py +73 -0
- custom_widgets_pro/_catalog.py +25 -0
- custom_widgets_pro/_cli.py +132 -0
- custom_widgets_pro/_license.py +419 -0
- custom_widgets_pro/_version.py +14 -0
- custom_widgets_pro/datatable/__init__.py +17 -0
- custom_widgets_pro/datatable/datatable_pro.py +319 -0
- custom_widgets_pro/datatable/export.py +143 -0
- custom_widgets_pro/datatable/frozen_view.py +112 -0
- custom_widgets_pro/datatable/grouping.py +309 -0
- custom_widgets_pro/datatable/pivot.py +100 -0
- custom_widgets_pro/datatable/provider.py +98 -0
- custom_widgets_pro/datatable/virtual_model.py +451 -0
- custom_widgets_pro/widgets/__init__.py +7 -0
- custom_widgets_pro/widgets/charts/QCustomBeeswarm.py +317 -0
- custom_widgets_pro/widgets/charts/QCustomBubbleChart.py +655 -0
- custom_widgets_pro/widgets/charts/QCustomCandlestickChart.py +522 -0
- custom_widgets_pro/widgets/charts/QCustomDivergingBarChart.py +430 -0
- custom_widgets_pro/widgets/charts/QCustomDotMatrix.py +266 -0
- custom_widgets_pro/widgets/charts/QCustomFunnelChart.py +474 -0
- custom_widgets_pro/widgets/charts/QCustomGanttChart.py +376 -0
- custom_widgets_pro/widgets/charts/QCustomHeatmap.py +555 -0
- custom_widgets_pro/widgets/charts/QCustomRadarChart.py +600 -0
- custom_widgets_pro/widgets/charts/QCustomRadialBars.py +441 -0
- custom_widgets_pro/widgets/charts/QCustomRadialLines.py +554 -0
- custom_widgets_pro/widgets/charts/QCustomRangeBarChart.py +504 -0
- custom_widgets_pro/widgets/charts/QCustomSankey.py +539 -0
- custom_widgets_pro/widgets/charts/QCustomScatterChart.py +623 -0
- custom_widgets_pro/widgets/charts/__init__.py +1 -0
- custom_widgets_pro/widgets/data/QCustomCodeEditor.py +391 -0
- custom_widgets_pro/widgets/data/QCustomNodeGraph.py +1030 -0
- custom_widgets_pro/widgets/data/QCustomRichTextEditor.py +189 -0
- custom_widgets_pro/widgets/data/QCustomTableToolbar.py +566 -0
- custom_widgets_pro/widgets/data/__init__.py +1 -0
- custom_widgets_pro/widgets/media/QCustomImageViewer.py +300 -0
- custom_widgets_pro/widgets/media/QCustomMediaGrid.py +197 -0
- custom_widgets_pro/widgets/media/QCustomMediaTimeline.py +607 -0
- custom_widgets_pro/widgets/media/QCustomVideoPlayer.py +326 -0
- custom_widgets_pro/widgets/media/__init__.py +1 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/METADATA +112 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/RECORD +45 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/WHEEL +5 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/entry_points.txt +2 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/licenses/LICENSE +91 -0
- 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()
|