1# -*- coding: utf-8 -*-
2from ..Qt import QtCore, QtGui, QtWidgets
3import weakref
4from ..graphicsItems.GraphicsObject import GraphicsObject
5from .. import functions as fn
6from ..Point import Point
7
8translate = QtCore.QCoreApplication.translate
9
10class Terminal(object):
11    def __init__(self, node, name, io, optional=False, multi=False, pos=None, renamable=False, removable=False, multiable=False, bypass=None):
12        """
13        Construct a new terminal.
14
15        ==============  =================================================================================
16        **Arguments:**
17        node            the node to which this terminal belongs
18        name            string, the name of the terminal
19        io              'in' or 'out'
20        optional        bool, whether the node may process without connection to this terminal
21        multi           bool, for inputs: whether this terminal may make multiple connections
22                        for outputs: whether this terminal creates a different value for each connection
23        pos             [x, y], the position of the terminal within its node's boundaries
24        renamable       (bool) Whether the terminal can be renamed by the user
25        removable       (bool) Whether the terminal can be removed by the user
26        multiable       (bool) Whether the user may toggle the *multi* option for this terminal
27        bypass          (str) Name of the terminal from which this terminal's value is derived
28                        when the Node is in bypass mode.
29        ==============  =================================================================================
30        """
31        self._io = io
32        self._optional = optional
33        self._multi = multi
34        self._node = weakref.ref(node)
35        self._name = name
36        self._renamable = renamable
37        self._removable = removable
38        self._multiable = multiable
39        self._connections = {}
40        self._graphicsItem = TerminalGraphicsItem(self, parent=self._node().graphicsItem())
41        self._bypass = bypass
42
43        if multi:
44            self._value = {}  ## dictionary of terminal:value pairs.
45        else:
46            self._value = None
47
48        self.valueOk = None
49        self.recolor()
50
51    def value(self, term=None):
52        """Return the value this terminal provides for the connected terminal"""
53        if term is None:
54            return self._value
55
56        if self.isMultiValue():
57            return self._value.get(term, None)
58        else:
59            return self._value
60
61    def bypassValue(self):
62        return self._bypass
63
64    def setValue(self, val, process=True):
65        """If this is a single-value terminal, val should be a single value.
66        If this is a multi-value terminal, val should be a dict of terminal:value pairs"""
67        if not self.isMultiValue():
68            if fn.eq(val, self._value):
69                return
70            self._value = val
71        else:
72            if not isinstance(self._value, dict):
73                self._value = {}
74            if val is not None:
75                self._value.update(val)
76
77        self.setValueAcceptable(None)  ## by default, input values are 'unchecked' until Node.update().
78        if self.isInput() and process:
79            self.node().update()
80
81        self.recolor()
82
83    def setOpts(self, **opts):
84        self._renamable = opts.get('renamable', self._renamable)
85        self._removable = opts.get('removable', self._removable)
86        self._multiable = opts.get('multiable', self._multiable)
87        if 'multi' in opts:
88            self.setMultiValue(opts['multi'])
89
90    def connected(self, term):
91        """Called whenever this terminal has been connected to another. (note--this function is called on both terminals)"""
92        if self.isInput() and term.isOutput():
93            self.inputChanged(term)
94        if self.isOutput() and self.isMultiValue():
95            self.node().update()
96        self.node().connected(self, term)
97
98    def disconnected(self, term):
99        """Called whenever this terminal has been disconnected from another. (note--this function is called on both terminals)"""
100        if self.isMultiValue() and term in self._value:
101            del self._value[term]
102            self.node().update()
103        else:
104            if self.isInput():
105                self.setValue(None)
106        self.node().disconnected(self, term)
107
108    def inputChanged(self, term, process=True):
109        """Called whenever there is a change to the input value to this terminal.
110        It may often be useful to override this function."""
111        if self.isMultiValue():
112            self.setValue({term: term.value(self)}, process=process)
113        else:
114            self.setValue(term.value(self), process=process)
115
116    def valueIsAcceptable(self):
117        """Returns True->acceptable  None->unknown  False->Unacceptable"""
118        return self.valueOk
119
120    def setValueAcceptable(self, v=True):
121        self.valueOk = v
122        self.recolor()
123
124    def connections(self):
125        return self._connections
126
127    def node(self):
128        return self._node()
129
130    def isInput(self):
131        return self._io == 'in'
132
133    def isMultiValue(self):
134        return self._multi
135
136    def setMultiValue(self, multi):
137        """Set whether this is a multi-value terminal."""
138        self._multi = multi
139        if not multi and len(self.inputTerminals()) > 1:
140            self.disconnectAll()
141
142        for term in self.inputTerminals():
143            self.inputChanged(term)
144
145    def isOutput(self):
146        return self._io == 'out'
147
148    def isRenamable(self):
149        return self._renamable
150
151    def isRemovable(self):
152        return self._removable
153
154    def isMultiable(self):
155        return self._multiable
156
157    def name(self):
158        return self._name
159
160    def graphicsItem(self):
161        return self._graphicsItem
162
163    def isConnected(self):
164        return len(self.connections()) > 0
165
166    def connectedTo(self, term):
167        return term in self.connections()
168
169    def hasInput(self):
170        for t in self.connections():
171            if t.isOutput():
172                return True
173        return False
174
175    def inputTerminals(self):
176        """Return the terminal(s) that give input to this one."""
177        return [t for t in self.connections() if t.isOutput()]
178
179    def dependentNodes(self):
180        """Return the list of nodes which receive input from this terminal."""
181        return set([t.node() for t in self.connections() if t.isInput()])
182
183    def connectTo(self, term, connectionItem=None):
184        try:
185            if self.connectedTo(term):
186                raise Exception('Already connected')
187            if term is self:
188                raise Exception('Not connecting terminal to self')
189            if term.node() is self.node():
190                raise Exception("Can't connect to terminal on same node.")
191            for t in [self, term]:
192                if t.isInput() and not t._multi and len(t.connections()) > 0:
193                    raise Exception("Cannot connect %s <-> %s: Terminal %s is already connected to %s (and does not allow multiple connections)" % (self, term, t, list(t.connections().keys())))
194        except:
195            if connectionItem is not None:
196                connectionItem.close()
197            raise
198
199        if connectionItem is None:
200            connectionItem = ConnectionItem(self.graphicsItem(), term.graphicsItem())
201            self.graphicsItem().getViewBox().addItem(connectionItem)
202        self._connections[term] = connectionItem
203        term._connections[self] = connectionItem
204
205        self.recolor()
206
207        self.connected(term)
208        term.connected(self)
209
210        return connectionItem
211
212    def disconnectFrom(self, term):
213        if not self.connectedTo(term):
214            return
215        item = self._connections[term]
216        item.close()
217        del self._connections[term]
218        del term._connections[self]
219        self.recolor()
220        term.recolor()
221
222        self.disconnected(term)
223        term.disconnected(self)
224
225
226    def disconnectAll(self):
227        for t in list(self._connections.keys()):
228            self.disconnectFrom(t)
229
230    def recolor(self, color=None, recurse=True):
231        if color is None:
232            if not self.isConnected():       ## disconnected terminals are black
233                color = QtGui.QColor(0,0,0)
234            elif self.isInput() and not self.hasInput():   ## input terminal with no connected output terminals
235                color = QtGui.QColor(200,200,0)
236            elif self._value is None or fn.eq(self._value, {}):  ## terminal is connected but has no data (possibly due to processing error)
237                color = QtGui.QColor(255,255,255)
238            elif self.valueIsAcceptable() is None:   ## terminal has data, but it is unknown if the data is ok
239                color = QtGui.QColor(200, 200, 0)
240            elif self.valueIsAcceptable() is True:   ## terminal has good input, all ok
241                color = QtGui.QColor(0, 200, 0)
242            else:                                    ## terminal has bad input
243                color = QtGui.QColor(200, 0, 0)
244        self.graphicsItem().setBrush(QtGui.QBrush(color))
245
246        if recurse:
247            for t in self.connections():
248                t.recolor(color, recurse=False)
249
250    def rename(self, name):
251        oldName = self._name
252        self._name = name
253        self.node().terminalRenamed(self, oldName)
254        self.graphicsItem().termRenamed(name)
255
256    def __repr__(self):
257        return "<Terminal %s.%s>" % (str(self.node().name()), str(self.name()))
258
259    def __hash__(self):
260        return id(self)
261
262    def close(self):
263        self.disconnectAll()
264        item = self.graphicsItem()
265        if item.scene() is not None:
266            item.scene().removeItem(item)
267
268    def saveState(self):
269        return {'io': self._io, 'multi': self._multi, 'optional': self._optional, 'renamable': self._renamable, 'removable': self._removable, 'multiable': self._multiable}
270
271    def __lt__(self, other):
272        """When the terminal is multi value, the data passed to the DatTreeWidget for each input or output, is {Terminal: value}.
273        To make this sortable, we provide the < operator.
274        """
275        return self._name < other._name
276
277
278class TextItem(QtWidgets.QGraphicsTextItem):
279    def __init__(self, text, parent, on_update):
280        super().__init__(text, parent)
281        self.on_update = on_update
282
283    def focusOutEvent(self, ev):
284        super().focusOutEvent(ev)
285        if self.on_update is not None:
286            self.on_update()
287
288    def keyPressEvent(self, ev):
289        if ev.key() == QtCore.Qt.Key.Key_Enter or ev.key() == QtCore.Qt.Key.Key_Return:
290            if self.on_update is not None:
291                self.on_update()
292                return
293        super().keyPressEvent(ev)
294
295
296class TerminalGraphicsItem(GraphicsObject):
297
298    def __init__(self, term, parent=None):
299        self.term = term
300        GraphicsObject.__init__(self, parent)
301        self.brush = fn.mkBrush(0,0,0)
302        self.box = QtGui.QGraphicsRectItem(0, 0, 10, 10, self)
303        on_update = self.labelChanged if self.term.isRenamable() else None
304        self.label = TextItem(self.term.name(), self, on_update)
305        self.label.setScale(0.7)
306        self.newConnection = None
307        self.setFiltersChildEvents(True)  ## to pick up mouse events on the rectitem
308        if self.term.isRenamable():
309            self.label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextEditorInteraction)
310        self.setZValue(1)
311        self.menu = None
312
313    def labelChanged(self):
314        newName = self.label.toPlainText()
315        if newName != self.term.name():
316            self.term.rename(newName)
317
318    def termRenamed(self, name):
319        self.label.setPlainText(name)
320
321    def setBrush(self, brush):
322        self.brush = brush
323        self.box.setBrush(brush)
324
325    def disconnect(self, target):
326        self.term.disconnectFrom(target.term)
327
328    def boundingRect(self):
329        br = self.box.mapRectToParent(self.box.boundingRect())
330        lr = self.label.mapRectToParent(self.label.boundingRect())
331        return br | lr
332
333    def paint(self, p, *args):
334        pass
335
336    def setAnchor(self, x, y):
337        pos = QtCore.QPointF(x, y)
338        self.anchorPos = pos
339        br = self.box.mapRectToParent(self.box.boundingRect())
340        lr = self.label.mapRectToParent(self.label.boundingRect())
341
342
343        if self.term.isInput():
344            self.box.setPos(pos.x(), pos.y()-br.height()/2.)
345            self.label.setPos(pos.x() + br.width(), pos.y() - lr.height()/2.)
346        else:
347            self.box.setPos(pos.x()-br.width(), pos.y()-br.height()/2.)
348            self.label.setPos(pos.x()-br.width()-lr.width(), pos.y()-lr.height()/2.)
349        self.updateConnections()
350
351    def updateConnections(self):
352        for t, c in self.term.connections().items():
353            c.updateLine()
354
355    def mousePressEvent(self, ev):
356        #ev.accept()
357        ev.ignore() ## necessary to allow click/drag events to process correctly
358
359    def mouseClickEvent(self, ev):
360        if ev.button() == QtCore.Qt.MouseButton.LeftButton:
361            ev.accept()
362            self.label.setFocus(QtCore.Qt.FocusReason.MouseFocusReason)
363        elif ev.button() == QtCore.Qt.MouseButton.RightButton:
364            ev.accept()
365            self.raiseContextMenu(ev)
366
367    def raiseContextMenu(self, ev):
368        ## only raise menu if this terminal is removable
369        menu = self.getMenu()
370        menu = self.scene().addParentContextMenus(self, menu, ev)
371        pos = ev.screenPos()
372        menu.popup(QtCore.QPoint(pos.x(), pos.y()))
373
374    def getMenu(self):
375        if self.menu is None:
376            self.menu = QtGui.QMenu()
377            self.menu.setTitle(translate("Context Menu", "Terminal"))
378            remAct = QtGui.QAction(translate("Context Menu", "Remove terminal"), self.menu)
379            remAct.triggered.connect(self.removeSelf)
380            self.menu.addAction(remAct)
381            self.menu.remAct = remAct
382            if not self.term.isRemovable():
383                remAct.setEnabled(False)
384            multiAct = QtGui.QAction(translate("Context Menu", "Multi-value"), self.menu)
385            multiAct.setCheckable(True)
386            multiAct.setChecked(self.term.isMultiValue())
387            multiAct.setEnabled(self.term.isMultiable())
388
389            multiAct.triggered.connect(self.toggleMulti)
390            self.menu.addAction(multiAct)
391            self.menu.multiAct = multiAct
392            if self.term.isMultiable():
393                multiAct.setEnabled = False
394        return self.menu
395
396    def toggleMulti(self):
397        multi = self.menu.multiAct.isChecked()
398        self.term.setMultiValue(multi)
399
400    def removeSelf(self):
401        self.term.node().removeTerminal(self.term)
402
403    def mouseDragEvent(self, ev):
404        if ev.button() != QtCore.Qt.MouseButton.LeftButton:
405            ev.ignore()
406            return
407
408        ev.accept()
409        if ev.isStart():
410            if self.newConnection is None:
411                self.newConnection = ConnectionItem(self)
412                #self.scene().addItem(self.newConnection)
413                self.getViewBox().addItem(self.newConnection)
414                #self.newConnection.setParentItem(self.parent().parent())
415
416            self.newConnection.setTarget(self.mapToView(ev.pos()))
417        elif ev.isFinish():
418            if self.newConnection is not None:
419                items = self.scene().items(ev.scenePos())
420                gotTarget = False
421                for i in items:
422                    if isinstance(i, TerminalGraphicsItem):
423                        self.newConnection.setTarget(i)
424                        try:
425                            self.term.connectTo(i.term, self.newConnection)
426                            gotTarget = True
427                        except:
428                            self.scene().removeItem(self.newConnection)
429                            self.newConnection = None
430                            raise
431                        break
432
433                if not gotTarget:
434                    self.newConnection.close()
435                self.newConnection = None
436        else:
437            if self.newConnection is not None:
438                self.newConnection.setTarget(self.mapToView(ev.pos()))
439
440    def hoverEvent(self, ev):
441        if not ev.isExit() and ev.acceptDrags(QtCore.Qt.MouseButton.LeftButton):
442            ev.acceptClicks(QtCore.Qt.MouseButton.LeftButton) ## we don't use the click, but we also don't want anyone else to use it.
443            ev.acceptClicks(QtCore.Qt.MouseButton.RightButton)
444            self.box.setBrush(fn.mkBrush('w'))
445        else:
446            self.box.setBrush(self.brush)
447        self.update()
448
449    def connectPoint(self):
450        ## return the connect position of this terminal in view coords
451        return self.mapToView(self.mapFromItem(self.box, self.box.boundingRect().center()))
452
453    def nodeMoved(self):
454        for t, item in self.term.connections().items():
455            item.updateLine()
456
457
458class ConnectionItem(GraphicsObject):
459
460    def __init__(self, source, target=None):
461        GraphicsObject.__init__(self)
462        self.setFlags(
463            self.GraphicsItemFlag.ItemIsSelectable |
464            self.GraphicsItemFlag.ItemIsFocusable
465        )
466        self.source = source
467        self.target = target
468        self.length = 0
469        self.hovered = False
470        self.path = None
471        self.shapePath = None
472        self.style = {
473            'shape': 'line',
474            'color': (100, 100, 250),
475            'width': 1.0,
476            'hoverColor': (150, 150, 250),
477            'hoverWidth': 1.0,
478            'selectedColor': (200, 200, 0),
479            'selectedWidth': 3.0,
480            }
481        self.source.getViewBox().addItem(self)
482        self.updateLine()
483        self.setZValue(0)
484
485    def close(self):
486        if self.scene() is not None:
487            self.scene().removeItem(self)
488
489    def setTarget(self, target):
490        self.target = target
491        self.updateLine()
492
493    def setStyle(self, **kwds):
494        self.style.update(kwds)
495        if 'shape' in kwds:
496            self.updateLine()
497        else:
498            self.update()
499
500    def updateLine(self):
501        start = Point(self.source.connectPoint())
502        if isinstance(self.target, TerminalGraphicsItem):
503            stop = Point(self.target.connectPoint())
504        elif isinstance(self.target, QtCore.QPointF):
505            stop = Point(self.target)
506        else:
507            return
508        self.prepareGeometryChange()
509
510        self.path = self.generatePath(start, stop)
511        self.shapePath = None
512        self.update()
513
514    def generatePath(self, start, stop):
515        path = QtGui.QPainterPath()
516        path.moveTo(start)
517        if self.style['shape'] == 'line':
518            path.lineTo(stop)
519        elif self.style['shape'] == 'cubic':
520            path.cubicTo(Point(stop.x(), start.y()), Point(start.x(), stop.y()), Point(stop.x(), stop.y()))
521        else:
522            raise Exception('Invalid shape "%s"; options are "line" or "cubic"' % self.style['shape'])
523        return path
524
525    def keyPressEvent(self, ev):
526        if not self.isSelected():
527            ev.ignore()
528            return
529
530        if ev.key() == QtCore.Qt.Key.Key_Delete or ev.key() == QtCore.Qt.Key.Key_Backspace:
531            self.source.disconnect(self.target)
532            ev.accept()
533        else:
534            ev.ignore()
535
536    def mousePressEvent(self, ev):
537        ev.ignore()
538
539    def mouseClickEvent(self, ev):
540        if ev.button() == QtCore.Qt.MouseButton.LeftButton:
541            ev.accept()
542            sel = self.isSelected()
543            self.setSelected(True)
544            self.setFocus()
545            if not sel and self.isSelected():
546                self.update()
547
548    def hoverEvent(self, ev):
549        if (not ev.isExit()) and ev.acceptClicks(QtCore.Qt.MouseButton.LeftButton):
550            self.hovered = True
551        else:
552            self.hovered = False
553        self.update()
554
555    def boundingRect(self):
556        return self.shape().boundingRect()
557
558    def viewRangeChanged(self):
559        self.shapePath = None
560        self.prepareGeometryChange()
561
562    def shape(self):
563        if self.shapePath is None:
564            if self.path is None:
565                return QtGui.QPainterPath()
566            stroker = QtGui.QPainterPathStroker()
567            px = self.pixelWidth()
568            stroker.setWidth(px*8)
569            self.shapePath = stroker.createStroke(self.path)
570        return self.shapePath
571
572    def paint(self, p, *args):
573        if self.isSelected():
574            p.setPen(fn.mkPen(self.style['selectedColor'], width=self.style['selectedWidth']))
575        else:
576            if self.hovered:
577                p.setPen(fn.mkPen(self.style['hoverColor'], width=self.style['hoverWidth']))
578            else:
579                p.setPen(fn.mkPen(self.style['color'], width=self.style['width']))
580
581        p.drawPath(self.path)
582