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