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,539 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# YOUTUBE: (SPINN TV) https://www.youtube.com/@SpinnTV
|
|
4
|
+
# WEBSITE: customwidgets.org
|
|
5
|
+
########################################################################
|
|
6
|
+
|
|
7
|
+
########################################################################
|
|
8
|
+
## QCustomSankey - a painted Sankey flow diagram.
|
|
9
|
+
##
|
|
10
|
+
## Nodes in columns joined by ribbons whose thickness is the quantity flowing.
|
|
11
|
+
## Energy budgets, traffic sources to conversions, spend breakdowns - anything
|
|
12
|
+
## where the interesting thing is where the volume GOES, not just its size.
|
|
13
|
+
##
|
|
14
|
+
## The hardest of the painted charts, because nothing about the layout is
|
|
15
|
+
## given: node columns, vertical order, and the stacking of each ribbon at
|
|
16
|
+
## both ends all have to be derived from the link list alone.
|
|
17
|
+
##
|
|
18
|
+
## QPainter only, NO QtCharts (see docs/design/mui-charts-gap.md).
|
|
19
|
+
##
|
|
20
|
+
## Data goes in with setLinks([...]) in code, or linksCsv in Qt Designer:
|
|
21
|
+
##
|
|
22
|
+
## linksCsv = "Search>Signup=120;Social>Signup=80;Signup>Paid=60"
|
|
23
|
+
## source > target = value, links separated by ";"
|
|
24
|
+
##
|
|
25
|
+
## Emits linkHovered(int), nodeHovered(str) and nodeClicked(str).
|
|
26
|
+
########################################################################
|
|
27
|
+
from qtpy.QtCore import Qt, Signal, Property, QRectF, QSize, QPointF
|
|
28
|
+
from qtpy.QtGui import (QColor, QPainter, QPen, QBrush, QPainterPath,
|
|
29
|
+
QFontMetrics, QLinearGradient)
|
|
30
|
+
from qtpy.QtWidgets import QWidget, QSizePolicy
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class QCustomSankey(QWidget):
|
|
34
|
+
linkHovered = Signal(int)
|
|
35
|
+
nodeHovered = Signal(str)
|
|
36
|
+
nodeClicked = Signal(str)
|
|
37
|
+
|
|
38
|
+
WIDGET_ICON = "components/icons/account_tree.png"
|
|
39
|
+
WIDGET_TOOLTIP = "A painted Sankey flow diagram"
|
|
40
|
+
WIDGET_MODULE = "Custom_Widgets.QCustomSankey"
|
|
41
|
+
WIDGET_DOM_XML = """
|
|
42
|
+
<ui language='c++'>
|
|
43
|
+
<widget class='QCustomSankey' name='customSankey'>
|
|
44
|
+
<property name='geometry'><rect><x>0</x><y>0</y><width>480</width><height>320</height></rect></property>
|
|
45
|
+
<property name='linksCsv'><string>Search>Signup=120;Social>Signup=80;Signup>Paid=60;Signup>Churn=140</string></property>
|
|
46
|
+
</widget>
|
|
47
|
+
</ui>
|
|
48
|
+
"""
|
|
49
|
+
__catalog__ = {
|
|
50
|
+
"name": "QCustomSankey",
|
|
51
|
+
"props": {"linksCsv": {"type": "string", "default": ""},
|
|
52
|
+
"nodeColorsCsv": {"type": "string", "default": ""},
|
|
53
|
+
"nodeWidth": {"type": "int", "default": 14},
|
|
54
|
+
"nodePadding": {"type": "int", "default": 12},
|
|
55
|
+
"linkOpacity": {"type": "float", "default": 0.4},
|
|
56
|
+
"curvature": {"type": "float", "default": 0.5},
|
|
57
|
+
"showLabels": {"type": "bool", "default": True},
|
|
58
|
+
"showValues": {"type": "bool", "default": False},
|
|
59
|
+
"labelColor": {"type": "color", "default": "#0f172a"}},
|
|
60
|
+
"signals": ["linkHovered", "nodeHovered", "nodeClicked"],
|
|
61
|
+
"tokens_used": ["on-surface"],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Rich editors for the Designer "Custom Properties" dock (see
|
|
65
|
+
# DesignerTools.CustomPropertiesDock).
|
|
66
|
+
DESIGNER_CUSTOM_PROPS = [
|
|
67
|
+
{"name": "linksCsv", "kind": "str", "group": "General"},
|
|
68
|
+
{"name": "nodeColorsCsv", "kind": "str", "group": "Colors"},
|
|
69
|
+
{"name": "nodeWidth", "kind": "int", "group": "General"},
|
|
70
|
+
{"name": "nodePadding", "kind": "int", "group": "General"},
|
|
71
|
+
{"name": "linkOpacity", "kind": "float", "group": "General"},
|
|
72
|
+
{"name": "curvature", "kind": "float", "group": "General"},
|
|
73
|
+
{"name": "showLabels", "kind": "bool", "group": "General"},
|
|
74
|
+
{"name": "showValues", "kind": "bool", "group": "General"},
|
|
75
|
+
{"name": "labelColor", "kind": "color", "group": "Colors"},
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
_DEFAULT_COLORS = ["#2563eb", "#16a34a", "#f59e0b", "#dc2626", "#7c3aed",
|
|
79
|
+
"#0891b2", "#db2777", "#65a30d"]
|
|
80
|
+
_PAD = 8.0
|
|
81
|
+
|
|
82
|
+
def __init__(self, parent=None, links=None):
|
|
83
|
+
super().__init__(parent)
|
|
84
|
+
self.setObjectName("QCustomSankey")
|
|
85
|
+
self._links = [] # list of (source, target, value)
|
|
86
|
+
self._nodeColors = {}
|
|
87
|
+
self._nodeWidth = 14
|
|
88
|
+
self._nodePadding = 12
|
|
89
|
+
self._linkOpacity = 0.4
|
|
90
|
+
self._curvature = 0.5
|
|
91
|
+
self._showLabels = True
|
|
92
|
+
self._showValues = False
|
|
93
|
+
self._hoverLink = -1
|
|
94
|
+
self._hoverNode = ""
|
|
95
|
+
self._nodeRects = {} # name -> QRectF
|
|
96
|
+
self._ribbons = [] # per-link QPainterPath
|
|
97
|
+
|
|
98
|
+
self._labelColor = QColor("#0f172a")
|
|
99
|
+
|
|
100
|
+
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
101
|
+
self.setMouseTracking(True)
|
|
102
|
+
if links:
|
|
103
|
+
self.setLinks(links)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ #
|
|
106
|
+
## Data
|
|
107
|
+
# ------------------------------------------------------------------ #
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _coerce(link):
|
|
110
|
+
if isinstance(link, dict):
|
|
111
|
+
source, target, value = (link.get("source"), link.get("target"),
|
|
112
|
+
link.get("value"))
|
|
113
|
+
elif isinstance(link, (tuple, list)) and len(link) >= 3:
|
|
114
|
+
source, target, value = link[0], link[1], link[2]
|
|
115
|
+
else:
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
value = float(value)
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
return None
|
|
121
|
+
source, target = str(source or ""), str(target or "")
|
|
122
|
+
if not source or not target or source == target or value <= 0:
|
|
123
|
+
return None
|
|
124
|
+
return (source, target, value)
|
|
125
|
+
|
|
126
|
+
def setLinks(self, links):
|
|
127
|
+
"""Replace every link. Self-loops, zero flows and unnamed ends are
|
|
128
|
+
dropped: none of them can be laid out, and keeping them would put a
|
|
129
|
+
ribbon of nowhere-to-nowhere in the diagram."""
|
|
130
|
+
self._links = [l for l in (self._coerce(x) for x in (links or [])) if l]
|
|
131
|
+
self._hoverLink = -1
|
|
132
|
+
self._hoverNode = ""
|
|
133
|
+
self.update()
|
|
134
|
+
|
|
135
|
+
def links(self):
|
|
136
|
+
return list(self._links)
|
|
137
|
+
|
|
138
|
+
def linkCount(self):
|
|
139
|
+
return len(self._links)
|
|
140
|
+
|
|
141
|
+
def clearLinks(self):
|
|
142
|
+
self._links = []
|
|
143
|
+
self._hoverLink = -1
|
|
144
|
+
self._hoverNode = ""
|
|
145
|
+
self.update()
|
|
146
|
+
|
|
147
|
+
def nodes(self):
|
|
148
|
+
"""Every node name, in first-seen order."""
|
|
149
|
+
seen, out = set(), []
|
|
150
|
+
for source, target, _v in self._links:
|
|
151
|
+
for name in (source, target):
|
|
152
|
+
if name not in seen:
|
|
153
|
+
seen.add(name)
|
|
154
|
+
out.append(name)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
def nodeDepth(self, name):
|
|
158
|
+
"""Column index: the longest path from any source-only node.
|
|
159
|
+
|
|
160
|
+
Longest, not shortest — a node fed by both a 1-hop and a 3-hop path
|
|
161
|
+
must sit after both, or its ribbons run backwards.
|
|
162
|
+
"""
|
|
163
|
+
depths = self.nodeDepths()
|
|
164
|
+
return depths.get(name, 0)
|
|
165
|
+
|
|
166
|
+
def nodeDepths(self):
|
|
167
|
+
names = self.nodes()
|
|
168
|
+
depths = {name: 0 for name in names}
|
|
169
|
+
# Relax repeatedly; bounded by node count so a cycle cannot spin.
|
|
170
|
+
for _round in range(len(names)):
|
|
171
|
+
changed = False
|
|
172
|
+
for source, target, _v in self._links:
|
|
173
|
+
candidate = depths[source] + 1
|
|
174
|
+
if candidate > depths[target]:
|
|
175
|
+
depths[target] = candidate
|
|
176
|
+
changed = True
|
|
177
|
+
if not changed:
|
|
178
|
+
break
|
|
179
|
+
return depths
|
|
180
|
+
|
|
181
|
+
def nodeValue(self, name):
|
|
182
|
+
"""Throughput: the larger of what flows in and what flows out."""
|
|
183
|
+
incoming = sum(v for _s, t, v in self._links if t == name)
|
|
184
|
+
outgoing = sum(v for s, _t, v in self._links if s == name)
|
|
185
|
+
return max(incoming, outgoing)
|
|
186
|
+
|
|
187
|
+
def columns(self):
|
|
188
|
+
"""[[node, ...], ...] left to right."""
|
|
189
|
+
depths = self.nodeDepths()
|
|
190
|
+
if not depths:
|
|
191
|
+
return []
|
|
192
|
+
out = [[] for _ in range(max(depths.values()) + 1)]
|
|
193
|
+
for name in self.nodes():
|
|
194
|
+
out[depths[name]].append(name)
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
def nodeColor(self, name):
|
|
198
|
+
if name in self._nodeColors:
|
|
199
|
+
return QColor(self._nodeColors[name])
|
|
200
|
+
order = self.nodes()
|
|
201
|
+
index = order.index(name) if name in order else 0
|
|
202
|
+
return QColor(self._DEFAULT_COLORS[index % len(self._DEFAULT_COLORS)])
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------ #
|
|
205
|
+
## Layout
|
|
206
|
+
# ------------------------------------------------------------------ #
|
|
207
|
+
def sizeHint(self):
|
|
208
|
+
return QSize(480, 320)
|
|
209
|
+
|
|
210
|
+
def minimumSizeHint(self):
|
|
211
|
+
return QSize(180, 140)
|
|
212
|
+
|
|
213
|
+
def _plotRect(self):
|
|
214
|
+
"""Reserve label room on BOTH sides.
|
|
215
|
+
|
|
216
|
+
Labels sit left of a node unless it is a sink, so insetting only the
|
|
217
|
+
right edge pushed every left-column label off-screen — "Search"
|
|
218
|
+
rendered as a stray "h".
|
|
219
|
+
"""
|
|
220
|
+
inset = self._PAD
|
|
221
|
+
if self._showLabels:
|
|
222
|
+
fm = QFontMetrics(self.font())
|
|
223
|
+
widest = max((fm.horizontalAdvance(self._labelTextFor(n))
|
|
224
|
+
for n in self.nodes()), default=0)
|
|
225
|
+
inset += widest + 9
|
|
226
|
+
return QRectF(inset, self._PAD,
|
|
227
|
+
max(1.0, self.width() - 2 * inset),
|
|
228
|
+
max(1.0, self.height() - 2 * self._PAD))
|
|
229
|
+
|
|
230
|
+
def _labelTextFor(self, name):
|
|
231
|
+
if self._showValues:
|
|
232
|
+
return "%s %g" % (name, self.nodeValue(name))
|
|
233
|
+
return name
|
|
234
|
+
|
|
235
|
+
def isSink(self, name):
|
|
236
|
+
"""True when nothing flows out of a node."""
|
|
237
|
+
return not any(s == name for s, _t, _v in self._links)
|
|
238
|
+
|
|
239
|
+
def _computeLayout(self):
|
|
240
|
+
"""Node rectangles, then ribbon paths stacked at both ends."""
|
|
241
|
+
self._nodeRects = {}
|
|
242
|
+
self._ribbons = []
|
|
243
|
+
columns = self.columns()
|
|
244
|
+
if not columns:
|
|
245
|
+
return
|
|
246
|
+
rect = self._plotRect()
|
|
247
|
+
colCount = len(columns)
|
|
248
|
+
gap = ((rect.width() - self._nodeWidth) / (colCount - 1)
|
|
249
|
+
if colCount > 1 else 0.0)
|
|
250
|
+
|
|
251
|
+
# Scale so the busiest column exactly fills the height.
|
|
252
|
+
scale = None
|
|
253
|
+
for column in columns:
|
|
254
|
+
total = sum(self.nodeValue(n) for n in column)
|
|
255
|
+
padding = self._nodePadding * max(0, len(column) - 1)
|
|
256
|
+
usable = max(1.0, rect.height() - padding)
|
|
257
|
+
if total > 0:
|
|
258
|
+
candidate = usable / total
|
|
259
|
+
scale = candidate if scale is None else min(scale, candidate)
|
|
260
|
+
scale = scale or 1.0
|
|
261
|
+
|
|
262
|
+
for ci, column in enumerate(columns):
|
|
263
|
+
heights = [max(1.0, self.nodeValue(n) * scale) for n in column]
|
|
264
|
+
used = sum(heights) + self._nodePadding * max(0, len(column) - 1)
|
|
265
|
+
y = rect.top() + (rect.height() - used) / 2.0
|
|
266
|
+
x = rect.left() + ci * gap
|
|
267
|
+
for name, height in zip(column, heights):
|
|
268
|
+
self._nodeRects[name] = QRectF(x, y, float(self._nodeWidth), height)
|
|
269
|
+
y += height + self._nodePadding
|
|
270
|
+
|
|
271
|
+
self._computeRibbons(scale)
|
|
272
|
+
|
|
273
|
+
def _computeRibbons(self, scale):
|
|
274
|
+
"""Stack each node's links in a stable order at both ends.
|
|
275
|
+
|
|
276
|
+
Sorting by the other end's vertical position keeps ribbons from
|
|
277
|
+
crossing more than the data forces them to.
|
|
278
|
+
"""
|
|
279
|
+
outOffsets = {name: 0.0 for name in self._nodeRects}
|
|
280
|
+
inOffsets = {name: 0.0 for name in self._nodeRects}
|
|
281
|
+
|
|
282
|
+
def targetY(link):
|
|
283
|
+
rect = self._nodeRects.get(link[1])
|
|
284
|
+
return rect.top() if rect else 0.0
|
|
285
|
+
|
|
286
|
+
def sourceY(link):
|
|
287
|
+
rect = self._nodeRects.get(link[0])
|
|
288
|
+
return rect.top() if rect else 0.0
|
|
289
|
+
|
|
290
|
+
order = sorted(range(len(self._links)),
|
|
291
|
+
key=lambda i: (sourceY(self._links[i]),
|
|
292
|
+
targetY(self._links[i])))
|
|
293
|
+
placement = {}
|
|
294
|
+
for i in order:
|
|
295
|
+
source, target, value = self._links[i]
|
|
296
|
+
if source not in self._nodeRects or target not in self._nodeRects:
|
|
297
|
+
continue
|
|
298
|
+
thickness = max(1.0, value * scale)
|
|
299
|
+
srcRect = self._nodeRects[source]
|
|
300
|
+
dstRect = self._nodeRects[target]
|
|
301
|
+
y0 = srcRect.top() + outOffsets[source]
|
|
302
|
+
y1 = dstRect.top() + inOffsets[target]
|
|
303
|
+
outOffsets[source] += thickness
|
|
304
|
+
inOffsets[target] += thickness
|
|
305
|
+
placement[i] = (srcRect.right(), y0, dstRect.left(), y1, thickness)
|
|
306
|
+
|
|
307
|
+
self._ribbons = []
|
|
308
|
+
for i in range(len(self._links)):
|
|
309
|
+
if i not in placement:
|
|
310
|
+
self._ribbons.append(QPainterPath())
|
|
311
|
+
continue
|
|
312
|
+
x0, y0, x1, y1, thickness = placement[i]
|
|
313
|
+
self._ribbons.append(self._ribbonPath(x0, y0, x1, y1, thickness))
|
|
314
|
+
|
|
315
|
+
def _ribbonPath(self, x0, y0, x1, y1, thickness):
|
|
316
|
+
"""A closed cubic band from (x0, y0) to (x1, y1)."""
|
|
317
|
+
bend = (x1 - x0) * max(0.0, min(1.0, self._curvature))
|
|
318
|
+
path = QPainterPath()
|
|
319
|
+
path.moveTo(x0, y0)
|
|
320
|
+
path.cubicTo(x0 + bend, y0, x1 - bend, y1, x1, y1)
|
|
321
|
+
path.lineTo(x1, y1 + thickness)
|
|
322
|
+
path.cubicTo(x1 - bend, y1 + thickness, x0 + bend, y0 + thickness,
|
|
323
|
+
x0, y0 + thickness)
|
|
324
|
+
path.closeSubpath()
|
|
325
|
+
return path
|
|
326
|
+
|
|
327
|
+
def nodeRects(self):
|
|
328
|
+
if not self._nodeRects:
|
|
329
|
+
self._computeLayout()
|
|
330
|
+
return dict(self._nodeRects)
|
|
331
|
+
|
|
332
|
+
def ribbons(self):
|
|
333
|
+
if not self._ribbons:
|
|
334
|
+
self._computeLayout()
|
|
335
|
+
return list(self._ribbons)
|
|
336
|
+
|
|
337
|
+
# ------------------------------------------------------------------ #
|
|
338
|
+
## Painting
|
|
339
|
+
# ------------------------------------------------------------------ #
|
|
340
|
+
def paintEvent(self, e):
|
|
341
|
+
p = QPainter(self)
|
|
342
|
+
p.setRenderHint(QPainter.Antialiasing)
|
|
343
|
+
p.setFont(self.font())
|
|
344
|
+
self._computeLayout()
|
|
345
|
+
if not self._nodeRects:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
for index, path in enumerate(self._ribbons):
|
|
349
|
+
if path.isEmpty():
|
|
350
|
+
continue
|
|
351
|
+
source, target, _value = self._links[index]
|
|
352
|
+
hovered = index == self._hoverLink or self._hoverNode in (source, target)
|
|
353
|
+
gradient = QLinearGradient(self._nodeRects[source].right(), 0,
|
|
354
|
+
self._nodeRects[target].left(), 0)
|
|
355
|
+
start = QColor(self.nodeColor(source))
|
|
356
|
+
stop = QColor(self.nodeColor(target))
|
|
357
|
+
alpha = min(1.0, self._linkOpacity * (1.8 if hovered else 1.0))
|
|
358
|
+
start.setAlphaF(alpha)
|
|
359
|
+
stop.setAlphaF(alpha)
|
|
360
|
+
gradient.setColorAt(0.0, start)
|
|
361
|
+
gradient.setColorAt(1.0, stop)
|
|
362
|
+
p.setPen(Qt.NoPen)
|
|
363
|
+
p.setBrush(QBrush(gradient))
|
|
364
|
+
p.drawPath(path)
|
|
365
|
+
|
|
366
|
+
for name, rect in self._nodeRects.items():
|
|
367
|
+
colour = self.nodeColor(name)
|
|
368
|
+
if name == self._hoverNode:
|
|
369
|
+
colour = colour.lighter(115)
|
|
370
|
+
p.setPen(Qt.NoPen)
|
|
371
|
+
p.setBrush(QBrush(colour))
|
|
372
|
+
p.drawRect(rect)
|
|
373
|
+
|
|
374
|
+
if self._showLabels:
|
|
375
|
+
self._paintLabels(p)
|
|
376
|
+
|
|
377
|
+
def _paintLabels(self, p):
|
|
378
|
+
fm = QFontMetrics(self.font())
|
|
379
|
+
p.setPen(QPen(self._labelColor))
|
|
380
|
+
for name, rect in self._nodeRects.items():
|
|
381
|
+
text = self._labelTextFor(name)
|
|
382
|
+
width = fm.horizontalAdvance(text)
|
|
383
|
+
# Sinks label to the right, everything else to the left, so a
|
|
384
|
+
# label never sits on top of the ribbons leaving its node. Keyed
|
|
385
|
+
# on "has no outgoing links", not on being in the last column: a
|
|
386
|
+
# dead end partway through is still a sink.
|
|
387
|
+
if self.isSink(name):
|
|
388
|
+
box = QRectF(rect.right() + 5, rect.center().y() - fm.height() / 2.0,
|
|
389
|
+
width + 4, fm.height())
|
|
390
|
+
align = Qt.AlignLeft
|
|
391
|
+
else:
|
|
392
|
+
box = QRectF(rect.left() - width - 9,
|
|
393
|
+
rect.center().y() - fm.height() / 2.0,
|
|
394
|
+
width + 4, fm.height())
|
|
395
|
+
align = Qt.AlignRight
|
|
396
|
+
p.drawText(box, int(align | Qt.AlignVCenter), text)
|
|
397
|
+
|
|
398
|
+
# ------------------------------------------------------------------ #
|
|
399
|
+
## Interaction
|
|
400
|
+
# ------------------------------------------------------------------ #
|
|
401
|
+
def nodeAt(self, pos):
|
|
402
|
+
point = QPointF(pos)
|
|
403
|
+
for name, rect in self.nodeRects().items():
|
|
404
|
+
if rect.adjusted(-2, -2, 2, 2).contains(point):
|
|
405
|
+
return name
|
|
406
|
+
return ""
|
|
407
|
+
|
|
408
|
+
def linkAt(self, pos):
|
|
409
|
+
point = QPointF(pos)
|
|
410
|
+
for index in range(len(self._ribbons) - 1, -1, -1):
|
|
411
|
+
if self._ribbons[index].contains(point):
|
|
412
|
+
return index
|
|
413
|
+
return -1
|
|
414
|
+
|
|
415
|
+
def mouseMoveEvent(self, e):
|
|
416
|
+
node = self.nodeAt(e.pos())
|
|
417
|
+
link = -1 if node else self.linkAt(e.pos())
|
|
418
|
+
if node != self._hoverNode:
|
|
419
|
+
self._hoverNode = node
|
|
420
|
+
self.nodeHovered.emit(node)
|
|
421
|
+
self.update()
|
|
422
|
+
if link != self._hoverLink:
|
|
423
|
+
self._hoverLink = link
|
|
424
|
+
self.linkHovered.emit(link)
|
|
425
|
+
self.update()
|
|
426
|
+
super().mouseMoveEvent(e)
|
|
427
|
+
|
|
428
|
+
def leaveEvent(self, e):
|
|
429
|
+
if self._hoverNode:
|
|
430
|
+
self._hoverNode = ""
|
|
431
|
+
self.nodeHovered.emit("")
|
|
432
|
+
if self._hoverLink != -1:
|
|
433
|
+
self._hoverLink = -1
|
|
434
|
+
self.linkHovered.emit(-1)
|
|
435
|
+
self.update()
|
|
436
|
+
super().leaveEvent(e)
|
|
437
|
+
|
|
438
|
+
def mouseReleaseEvent(self, e):
|
|
439
|
+
if e.button() == Qt.LeftButton:
|
|
440
|
+
name = self.nodeAt(e.pos())
|
|
441
|
+
if name:
|
|
442
|
+
self.nodeClicked.emit(name)
|
|
443
|
+
super().mouseReleaseEvent(e)
|
|
444
|
+
|
|
445
|
+
# ------------------------------------------------------------------ #
|
|
446
|
+
## Designer properties
|
|
447
|
+
# ------------------------------------------------------------------ #
|
|
448
|
+
@Property(str)
|
|
449
|
+
def linksCsv(self):
|
|
450
|
+
return ";".join("%s>%s=%g" % (s, t, v) for s, t, v in self._links)
|
|
451
|
+
|
|
452
|
+
@linksCsv.setter
|
|
453
|
+
def linksCsv(self, text):
|
|
454
|
+
links = []
|
|
455
|
+
for chunk in str(text).replace("|", ";").split(";"):
|
|
456
|
+
chunk = chunk.strip()
|
|
457
|
+
if not chunk or ">" not in chunk or "=" not in chunk:
|
|
458
|
+
continue
|
|
459
|
+
pair, _, raw = chunk.partition("=")
|
|
460
|
+
source, _, target = pair.partition(">")
|
|
461
|
+
coerced = self._coerce((source.strip(), target.strip(), raw.strip()))
|
|
462
|
+
if coerced:
|
|
463
|
+
links.append(coerced)
|
|
464
|
+
self.setLinks(links)
|
|
465
|
+
|
|
466
|
+
@Property(str)
|
|
467
|
+
def nodeColorsCsv(self):
|
|
468
|
+
return ",".join("%s=%s" % (name, colour.name())
|
|
469
|
+
for name, colour in self._nodeColors.items())
|
|
470
|
+
|
|
471
|
+
@nodeColorsCsv.setter
|
|
472
|
+
def nodeColorsCsv(self, text):
|
|
473
|
+
colors = {}
|
|
474
|
+
for chunk in str(text).replace(";", ",").split(","):
|
|
475
|
+
chunk = chunk.strip()
|
|
476
|
+
if "=" not in chunk:
|
|
477
|
+
continue
|
|
478
|
+
name, _, raw = chunk.partition("=")
|
|
479
|
+
colour = QColor(raw.strip())
|
|
480
|
+
if name.strip() and colour.isValid():
|
|
481
|
+
colors[name.strip()] = colour
|
|
482
|
+
self._nodeColors = colors
|
|
483
|
+
self.update()
|
|
484
|
+
|
|
485
|
+
@Property(int)
|
|
486
|
+
def nodeWidth(self):
|
|
487
|
+
return self._nodeWidth
|
|
488
|
+
|
|
489
|
+
@nodeWidth.setter
|
|
490
|
+
def nodeWidth(self, value):
|
|
491
|
+
self._nodeWidth = max(2, int(value)); self.update()
|
|
492
|
+
|
|
493
|
+
@Property(int)
|
|
494
|
+
def nodePadding(self):
|
|
495
|
+
return self._nodePadding
|
|
496
|
+
|
|
497
|
+
@nodePadding.setter
|
|
498
|
+
def nodePadding(self, value):
|
|
499
|
+
self._nodePadding = max(0, int(value)); self.update()
|
|
500
|
+
|
|
501
|
+
@Property(float)
|
|
502
|
+
def linkOpacity(self):
|
|
503
|
+
return self._linkOpacity
|
|
504
|
+
|
|
505
|
+
@linkOpacity.setter
|
|
506
|
+
def linkOpacity(self, value):
|
|
507
|
+
self._linkOpacity = max(0.0, min(1.0, float(value))); self.update()
|
|
508
|
+
|
|
509
|
+
@Property(float)
|
|
510
|
+
def curvature(self):
|
|
511
|
+
return self._curvature
|
|
512
|
+
|
|
513
|
+
@curvature.setter
|
|
514
|
+
def curvature(self, value):
|
|
515
|
+
self._curvature = max(0.0, min(1.0, float(value))); self.update()
|
|
516
|
+
|
|
517
|
+
@Property(bool)
|
|
518
|
+
def showLabels(self):
|
|
519
|
+
return self._showLabels
|
|
520
|
+
|
|
521
|
+
@showLabels.setter
|
|
522
|
+
def showLabels(self, value):
|
|
523
|
+
self._showLabels = bool(value); self.update()
|
|
524
|
+
|
|
525
|
+
@Property(bool)
|
|
526
|
+
def showValues(self):
|
|
527
|
+
return self._showValues
|
|
528
|
+
|
|
529
|
+
@showValues.setter
|
|
530
|
+
def showValues(self, value):
|
|
531
|
+
self._showValues = bool(value); self.update()
|
|
532
|
+
|
|
533
|
+
@Property(QColor)
|
|
534
|
+
def labelColor(self):
|
|
535
|
+
return self._labelColor
|
|
536
|
+
|
|
537
|
+
@labelColor.setter
|
|
538
|
+
def labelColor(self, c):
|
|
539
|
+
self._labelColor = QColor(c); self.update()
|