1""" 2==================== 3Scheme Editor Widget 4==================== 5 6 7""" 8import io 9import logging 10import itertools 11import sys 12import unicodedata 13import copy 14import dictdiffer 15 16from operator import attrgetter 17from urllib.parse import urlencode 18from contextlib import ExitStack, contextmanager 19from typing import ( 20 List, Tuple, Optional, Container, Dict, Any, Iterable, Generator, Sequence 21) 22 23from AnyQt.QtWidgets import ( 24 QWidget, QVBoxLayout, QMenu, QAction, QActionGroup, 25 QUndoStack, QGraphicsItem, QGraphicsTextItem, 26 QFormLayout, QComboBox, QDialog, QDialogButtonBox, QMessageBox, QCheckBox, 27 QGraphicsSceneDragDropEvent, QGraphicsSceneMouseEvent, 28 QGraphicsSceneContextMenuEvent, QGraphicsView, QGraphicsScene, 29 QApplication 30) 31from AnyQt.QtGui import ( 32 QKeySequence, QCursor, QFont, QPainter, QPixmap, QColor, QIcon, 33 QWhatsThisClickedEvent, QKeyEvent, QPalette 34) 35from AnyQt.QtCore import ( 36 Qt, QObject, QEvent, QSignalMapper, QCoreApplication, QPointF, 37 QMimeData, Slot) 38from AnyQt.QtCore import pyqtProperty as Property, pyqtSignal as Signal 39 40from orangecanvas.document.commands import UndoCommand 41from .interactions import DropHandler 42from ..registry import WidgetDescription, WidgetRegistry 43from .suggestions import Suggestions 44from .usagestatistics import UsageStatistics 45from ..registry.qt import whats_this_helper, QtWidgetRegistry 46from ..gui.quickhelp import QuickHelpTipEvent 47from ..gui.utils import ( 48 message_information, disabled, clipboard_has_format, clipboard_data 49) 50from ..scheme import ( 51 scheme, signalmanager, Scheme, SchemeNode, SchemeLink, 52 BaseSchemeAnnotation, SchemeTextAnnotation, WorkflowEvent 53) 54from ..scheme.widgetmanager import WidgetManager 55from ..canvas.scene import CanvasScene 56from ..canvas.view import CanvasView 57from ..canvas import items 58from ..canvas.items.annotationitem import Annotation as AnnotationItem 59from . import interactions 60from . import commands 61from . import quickmenu 62from ..utils import findf, UNUSED 63from ..utils.qinvoke import connect_with_context 64 65Pos = Tuple[float, float] 66RuntimeState = signalmanager.SignalManager.State 67 68# Private MIME type for clipboard contents 69MimeTypeWorkflowFragment = "application/vnd.{}-ows-fragment+xml".format(__name__) 70 71log = logging.getLogger(__name__) 72 73DuplicateOffset = QPointF(0, 120) 74 75 76class NoWorkflowError(RuntimeError): 77 def __init__(self, message: str = "No workflow model is set", **kwargs): 78 super().__init__(message, *kwargs) 79 80 81class UndoStack(QUndoStack): 82 83 indexIncremented = Signal() 84 85 def __init__(self, parent, statistics: UsageStatistics): 86 QUndoStack.__init__(self, parent) 87 self.__statistics = statistics 88 self.__previousIndex = self.index() 89 self.__currentIndex = self.index() 90 91 self.indexChanged.connect(self.__refreshIndex) 92 93 @Slot(int) 94 def __refreshIndex(self, newIndex): 95 self.__previousIndex = self.__currentIndex 96 self.__currentIndex = newIndex 97 98 if self.__previousIndex < newIndex: 99 self.indexIncremented.emit() 100 101 @Slot() 102 def undo(self): 103 self.__statistics.begin_action(UsageStatistics.Undo) 104 super().undo() 105 self.__statistics.end_action() 106 107 @Slot() 108 def redo(self): 109 self.__statistics.begin_action(UsageStatistics.Redo) 110 super().redo() 111 self.__statistics.end_action() 112 113 def push(self, macro): 114 super().push(macro) 115 self.__statistics.end_action() 116 117 118class SchemeEditWidget(QWidget): 119 """ 120 A widget for editing a :class:`~.scheme.Scheme` instance. 121 122 """ 123 #: Undo command has become available/unavailable. 124 undoAvailable = Signal(bool) 125 126 #: Redo command has become available/unavailable. 127 redoAvailable = Signal(bool) 128 129 #: Document modified state has changed. 130 modificationChanged = Signal(bool) 131 132 #: Undo command was added to the undo stack. 133 undoCommandAdded = Signal() 134 135 #: Item selection has changed. 136 selectionChanged = Signal() 137 138 #: Document title has changed. 139 titleChanged = Signal(str) 140 141 #: Document path has changed. 142 pathChanged = Signal(str) 143 144 # Quick Menu triggers 145 (NoTriggers, 146 RightClicked, 147 DoubleClicked, 148 SpaceKey, 149 AnyKey) = [0, 1, 2, 4, 8] 150 151 def __init__(self, parent=None, ): 152 super().__init__(parent) 153 154 self.__modified = False 155 self.__registry = None # type: Optional[WidgetRegistry] 156 self.__scheme = None # type: Optional[Scheme] 157 158 self.__widgetManager = None # type: Optional[WidgetManager] 159 self.__path = "" 160 161 self.__quickMenuTriggers = SchemeEditWidget.SpaceKey | \ 162 SchemeEditWidget.DoubleClicked 163 self.__emptyClickButtons = 0 164 self.__channelNamesVisible = True 165 self.__nodeAnimationEnabled = True 166 self.__possibleSelectionHandler = None 167 self.__possibleMouseItemsMove = False 168 self.__itemsMoving = {} 169 self.__contextMenuTarget = None # type: Optional[SchemeLink] 170 self.__dropTarget = None # type: Optional[items.LinkItem] 171 self.__quickMenu = None # type: Optional[quickmenu.QuickMenu] 172 self.__quickTip = "" 173 174 self.__statistics = UsageStatistics(self) 175 176 self.__undoStack = UndoStack(self, self.__statistics) 177 self.__undoStack.cleanChanged[bool].connect(self.__onCleanChanged) 178 self.__undoStack.indexIncremented.connect(self.undoCommandAdded) 179 180 # Preferred position for paste command. Updated on every mouse button 181 # press and copy operation. 182 self.__pasteOrigin = QPointF(20, 20) 183 184 # scheme node properties when set to a clean state 185 self.__cleanProperties = {} 186 187 # list of links when set to a clean state 188 self.__cleanLinks = [] 189 190 # list of annotations when set to a clean state 191 self.__cleanAnnotations = [] 192 193 self.__dropHandlers = () # type: Sequence[DropHandler] 194 195 self.__editFinishedMapper = QSignalMapper(self) 196 self.__editFinishedMapper.mapped[QObject].connect( 197 self.__onEditingFinished 198 ) 199 200 self.__annotationGeomChanged = QSignalMapper(self) 201 202 self.__setupActions() 203 self.__setupUi() 204 205 # Edit menu for a main window menu bar. 206 self.__editMenu = QMenu(self.tr("&Edit"), self) 207 self.__editMenu.addAction(self.__undoAction) 208 self.__editMenu.addAction(self.__redoAction) 209 self.__editMenu.addSeparator() 210 self.__editMenu.addAction(self.__removeSelectedAction) 211 self.__editMenu.addAction(self.__duplicateSelectedAction) 212 self.__editMenu.addAction(self.__copySelectedAction) 213 self.__editMenu.addAction(self.__pasteAction) 214 self.__editMenu.addAction(self.__selectAllAction) 215 216 # Widget context menu 217 self.__widgetMenu = QMenu(self.tr("Widget"), self) 218 self.__widgetMenu.addAction(self.__openSelectedAction) 219 self.__widgetMenu.addSeparator() 220 self.__widgetMenu.addAction(self.__renameAction) 221 self.__widgetMenu.addAction(self.__removeSelectedAction) 222 self.__widgetMenu.addAction(self.__duplicateSelectedAction) 223 self.__widgetMenu.addAction(self.__copySelectedAction) 224 self.__widgetMenu.addSeparator() 225 self.__widgetMenu.addAction(self.__helpAction) 226 227 # Widget menu for a main window menu bar. 228 self.__menuBarWidgetMenu = QMenu(self.tr("&Widget"), self) 229 self.__menuBarWidgetMenu.addAction(self.__openSelectedAction) 230 self.__menuBarWidgetMenu.addSeparator() 231 self.__menuBarWidgetMenu.addAction(self.__renameAction) 232 self.__menuBarWidgetMenu.addAction(self.__removeSelectedAction) 233 self.__menuBarWidgetMenu.addSeparator() 234 self.__menuBarWidgetMenu.addAction(self.__helpAction) 235 236 self.__linkMenu = QMenu(self.tr("Link"), self) 237 self.__linkMenu.addAction(self.__linkEnableAction) 238 self.__linkMenu.addSeparator() 239 self.__linkMenu.addAction(self.__nodeInsertAction) 240 self.__linkMenu.addSeparator() 241 self.__linkMenu.addAction(self.__linkRemoveAction) 242 self.__linkMenu.addAction(self.__linkResetAction) 243 244 self.__suggestions = Suggestions() 245 246 def __setupActions(self): 247 self.__cleanUpAction = QAction( 248 self.tr("Clean Up"), self, 249 objectName="cleanup-action", 250 shortcut=QKeySequence("Shift+A"), 251 toolTip=self.tr("Align widgets to a grid (Shift+A)"), 252 triggered=self.alignToGrid, 253 ) 254 255 self.__newTextAnnotationAction = QAction( 256 self.tr("Text"), self, 257 objectName="new-text-action", 258 toolTip=self.tr("Add a text annotation to the workflow."), 259 checkable=True, 260 toggled=self.__toggleNewTextAnnotation, 261 ) 262 263 # Create a font size menu for the new annotation action. 264 self.__fontMenu = QMenu("Font Size", self) 265 self.__fontActionGroup = group = QActionGroup( 266 self, triggered=self.__onFontSizeTriggered 267 ) 268 269 def font(size): 270 f = QFont(self.font()) 271 f.setPixelSize(size) 272 return f 273 274 for size in [12, 14, 16, 18, 20, 22, 24]: 275 action = QAction( 276 "%ipx" % size, group, checkable=True, font=font(size) 277 ) 278 self.__fontMenu.addAction(action) 279 280 group.actions()[2].setChecked(True) 281 282 self.__newTextAnnotationAction.setMenu(self.__fontMenu) 283 284 self.__newArrowAnnotationAction = QAction( 285 self.tr("Arrow"), self, 286 objectName="new-arrow-action", 287 toolTip=self.tr("Add a arrow annotation to the workflow."), 288 checkable=True, 289 toggled=self.__toggleNewArrowAnnotation, 290 ) 291 292 # Create a color menu for the arrow annotation action 293 self.__arrowColorMenu = QMenu("Arrow Color",) 294 self.__arrowColorActionGroup = group = QActionGroup( 295 self, triggered=self.__onArrowColorTriggered 296 ) 297 298 def color_icon(color): 299 icon = QIcon() 300 for size in [16, 24, 32]: 301 pixmap = QPixmap(size, size) 302 pixmap.fill(QColor(0, 0, 0, 0)) 303 p = QPainter(pixmap) 304 p.setRenderHint(QPainter.Antialiasing) 305 p.setBrush(color) 306 p.setPen(Qt.NoPen) 307 p.drawEllipse(1, 1, size - 2, size - 2) 308 p.end() 309 icon.addPixmap(pixmap) 310 return icon 311 312 for color in ["#000", "#C1272D", "#662D91", "#1F9CDF", "#39B54A"]: 313 icon = color_icon(QColor(color)) 314 action = QAction(group, icon=icon, checkable=True, 315 iconVisibleInMenu=True) 316 action.setData(color) 317 self.__arrowColorMenu.addAction(action) 318 319 group.actions()[1].setChecked(True) 320 321 self.__newArrowAnnotationAction.setMenu(self.__arrowColorMenu) 322 323 self.__undoAction = self.__undoStack.createUndoAction(self) 324 self.__undoAction.setShortcut(QKeySequence.Undo) 325 self.__undoAction.setObjectName("undo-action") 326 327 self.__redoAction = self.__undoStack.createRedoAction(self) 328 self.__redoAction.setShortcut(QKeySequence.Redo) 329 self.__redoAction.setObjectName("redo-action") 330 331 self.__selectAllAction = QAction( 332 self.tr("Select all"), self, 333 objectName="select-all-action", 334 toolTip=self.tr("Select all items."), 335 triggered=self.selectAll, 336 shortcut=QKeySequence.SelectAll 337 ) 338 self.__openSelectedAction = QAction( 339 self.tr("Open"), self, 340 objectName="open-action", 341 toolTip=self.tr("Open selected widget"), 342 triggered=self.openSelected, 343 enabled=False 344 ) 345 self.__removeSelectedAction = QAction( 346 self.tr("Remove"), self, 347 objectName="remove-selected", 348 toolTip=self.tr("Remove selected items"), 349 triggered=self.removeSelected, 350 enabled=False 351 ) 352 353 shortcuts = [Qt.Key_Backspace, 354 Qt.Key_Delete, 355 Qt.ControlModifier + Qt.Key_Backspace] 356 357 self.__removeSelectedAction.setShortcuts(shortcuts) 358 359 self.__renameAction = QAction( 360 self.tr("Rename"), self, 361 objectName="rename-action", 362 toolTip=self.tr("Rename selected widget"), 363 triggered=self.__onRenameAction, 364 shortcut=QKeySequence(Qt.Key_F2), 365 enabled=False 366 ) 367 if sys.platform == "darwin": 368 self.__renameAction.setShortcuts([ 369 QKeySequence(Qt.Key_F2), 370 QKeySequence(Qt.Key_Enter), 371 QKeySequence(Qt.Key_Return) 372 ]) 373 374 self.__helpAction = QAction( 375 self.tr("Help"), self, 376 objectName="help-action", 377 toolTip=self.tr("Show widget help"), 378 triggered=self.__onHelpAction, 379 shortcut=QKeySequence("F1"), 380 enabled=False, 381 ) 382 self.__linkEnableAction = QAction( 383 self.tr("Enabled"), self, objectName="link-enable-action", 384 triggered=self.__toggleLinkEnabled, checkable=True, 385 ) 386 387 self.__linkRemoveAction = QAction( 388 self.tr("Remove"), self, 389 objectName="link-remove-action", 390 triggered=self.__linkRemove, 391 toolTip=self.tr("Remove link."), 392 ) 393 394 self.__nodeInsertAction = QAction( 395 self.tr("Insert Widget"), self, 396 objectName="node-insert-action", 397 triggered=self.__nodeInsert, 398 toolTip=self.tr("Insert widget."), 399 ) 400 401 self.__linkResetAction = QAction( 402 self.tr("Reset Signals"), self, 403 objectName="link-reset-action", 404 triggered=self.__linkReset, 405 ) 406 407 self.__duplicateSelectedAction = QAction( 408 self.tr("Duplicate"), self, 409 objectName="duplicate-action", 410 enabled=False, 411 shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_D), 412 triggered=self.__duplicateSelected, 413 ) 414 415 self.__copySelectedAction = QAction( 416 self.tr("Copy"), self, 417 objectName="copy-action", 418 enabled=False, 419 shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_C), 420 triggered=self.__copyToClipboard, 421 ) 422 423 self.__pasteAction = QAction( 424 self.tr("Paste"), self, 425 objectName="paste-action", 426 enabled=clipboard_has_format(MimeTypeWorkflowFragment), 427 shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_V), 428 triggered=self.__pasteFromClipboard, 429 ) 430 QApplication.clipboard().dataChanged.connect( 431 self.__updatePasteActionState 432 ) 433 434 self.addActions([ 435 self.__newTextAnnotationAction, 436 self.__newArrowAnnotationAction, 437 self.__linkEnableAction, 438 self.__linkRemoveAction, 439 self.__nodeInsertAction, 440 self.__linkResetAction, 441 self.__duplicateSelectedAction, 442 self.__copySelectedAction, 443 self.__pasteAction 444 ]) 445 446 # Actions which should be disabled while a multistep 447 # interaction is in progress. 448 self.__disruptiveActions = [ 449 self.__undoAction, 450 self.__redoAction, 451 self.__removeSelectedAction, 452 self.__selectAllAction, 453 self.__duplicateSelectedAction, 454 self.__copySelectedAction, 455 self.__pasteAction 456 ] 457 458 #: Top 'Window Groups' action 459 self.__windowGroupsAction = QAction( 460 self.tr("Window Groups"), self, objectName="window-groups-action", 461 toolTip="Manage preset widget groups" 462 ) 463 #: Action group containing action for every window group 464 self.__windowGroupsActionGroup = QActionGroup( 465 self.__windowGroupsAction, objectName="window-groups-action-group", 466 ) 467 self.__windowGroupsActionGroup.triggered.connect( 468 self.__activateWindowGroup 469 ) 470 self.__saveWindowGroupAction = QAction( 471 self.tr("Save Window Group..."), self, 472 objectName="window-groups-save-action", 473 toolTip="Create and save a new window group." 474 ) 475 self.__saveWindowGroupAction.triggered.connect(self.__saveWindowGroup) 476 self.__clearWindowGroupsAction = QAction( 477 self.tr("Delete All Groups"), self, 478 objectName="window-groups-clear-action", 479 toolTip="Delete all saved widget presets" 480 ) 481 self.__clearWindowGroupsAction.triggered.connect( 482 self.__clearWindowGroups 483 ) 484 485 groups_menu = QMenu(self) 486 sep = groups_menu.addSeparator() 487 sep.setObjectName("groups-separator") 488 groups_menu.addAction(self.__saveWindowGroupAction) 489 groups_menu.addSeparator() 490 groups_menu.addAction(self.__clearWindowGroupsAction) 491 self.__windowGroupsAction.setMenu(groups_menu) 492 493 # the counterpart to Control + Key_Up to raise the containing workflow 494 # view (maybe move that shortcut here) 495 self.__raiseWidgetsAction = QAction( 496 self.tr("Bring Widgets to Front"), self, 497 objectName="bring-widgets-to-front-action", 498 shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_Down), 499 shortcutContext=Qt.WindowShortcut, 500 ) 501 self.__raiseWidgetsAction.triggered.connect(self.__raiseToFont) 502 self.addAction(self.__raiseWidgetsAction) 503 504 def __setupUi(self): 505 layout = QVBoxLayout() 506 layout.setContentsMargins(0, 0, 0, 0) 507 layout.setSpacing(0) 508 509 scene = CanvasScene(self) 510 scene.setItemIndexMethod(CanvasScene.NoIndex) 511 self.__setupScene(scene) 512 513 view = CanvasView(scene) 514 view.setFrameStyle(CanvasView.NoFrame) 515 view.setRenderHint(QPainter.Antialiasing) 516 517 self.__view = view 518 self.__scene = scene 519 520 layout.addWidget(view) 521 self.setLayout(layout) 522 523 def __setupScene(self, scene): 524 # type: (CanvasScene) -> None 525 """ 526 Set up a :class:`CanvasScene` instance for use by the editor. 527 528 .. note:: If an existing scene is in use it must be teared down using 529 __teardownScene 530 """ 531 scene.set_channel_names_visible(self.__channelNamesVisible) 532 scene.set_node_animation_enabled( 533 self.__nodeAnimationEnabled 534 ) 535 536 scene.setFont(self.font()) 537 scene.setPalette(self.palette()) 538 scene.installEventFilter(self) 539 540 if self.__registry is not None: 541 scene.set_registry(self.__registry) 542 scene.focusItemChanged.connect(self.__onFocusItemChanged) 543 scene.selectionChanged.connect(self.__onSelectionChanged) 544 scene.link_item_activated.connect(self.__onLinkActivate) 545 scene.link_item_added.connect(self.__onLinkAdded) 546 scene.node_item_activated.connect(self.__onNodeActivate) 547 scene.annotation_added.connect(self.__onAnnotationAdded) 548 scene.annotation_removed.connect(self.__onAnnotationRemoved) 549 self.__annotationGeomChanged = QSignalMapper(self) 550 551 def __teardownScene(self, scene): 552 # type: (CanvasScene) -> None 553 """ 554 Tear down an instance of :class:`CanvasScene` that was used by the 555 editor. 556 """ 557 # Clear the current item selection in the scene so edit action 558 # states are updated accordingly. 559 scene.clearSelection() 560 561 # Clear focus from any item. 562 scene.setFocusItem(None) 563 564 # Clear the annotation mapper 565 self.__annotationGeomChanged.deleteLater() 566 self.__annotationGeomChanged = None 567 scene.focusItemChanged.disconnect(self.__onFocusItemChanged) 568 scene.selectionChanged.disconnect(self.__onSelectionChanged) 569 scene.removeEventFilter(self) 570 571 # Clear all items from the scene 572 scene.blockSignals(True) 573 scene.clear_scene() 574 575 def toolbarActions(self): 576 # type: () -> List[QAction] 577 """ 578 Return a list of actions that can be inserted into a toolbar. 579 At the moment these are: 580 581 - 'Zoom in' action 582 - 'Zoom out' action 583 - 'Zoom Reset' action 584 - 'Clean up' action (align to grid) 585 - 'New text annotation' action (with a size menu) 586 - 'New arrow annotation' action (with a color menu) 587 588 """ 589 view = self.__view 590 zoomin = view.findChild(QAction, "action-zoom-in") 591 zoomout = view.findChild(QAction, "action-zoom-out") 592 zoomreset = view.findChild(QAction, "action-zoom-reset") 593 assert zoomin and zoomout and zoomreset 594 return [zoomin, 595 zoomout, 596 zoomreset, 597 self.__cleanUpAction, 598 self.__newTextAnnotationAction, 599 self.__newArrowAnnotationAction] 600 601 def menuBarActions(self): 602 # type: () -> List[QAction] 603 """ 604 Return a list of actions that can be inserted into a `QMenuBar`. 605 606 """ 607 return [self.__editMenu.menuAction(), 608 self.__menuBarWidgetMenu.menuAction()] 609 610 def isModified(self): 611 # type: () -> bool 612 """ 613 Is the document is a modified state. 614 """ 615 return self.__modified or not self.__undoStack.isClean() 616 617 def setModified(self, modified): 618 # type: (bool) -> None 619 """ 620 Set the document modified state. 621 """ 622 if self.__modified != modified: 623 self.__modified = modified 624 625 if not modified: 626 if self.__scheme: 627 self.__cleanProperties = node_properties(self.__scheme) 628 self.__cleanLinks = self.__scheme.links 629 self.__cleanAnnotations = self.__scheme.annotations 630 else: 631 self.__cleanProperties = {} 632 self.__cleanLinks = [] 633 self.__cleanAnnotations = [] 634 self.__undoStack.setClean() 635 else: 636 self.__cleanProperties = {} 637 self.__cleanLinks = [] 638 self.__cleanAnnotations = [] 639 640 modified = Property(bool, fget=isModified, fset=setModified) 641 642 def isModifiedStrict(self): 643 """ 644 Is the document modified. 645 646 Run a strict check against all node properties as they were 647 at the time when the last call to `setModified(True)` was made. 648 649 """ 650 propertiesChanged = self.__cleanProperties != \ 651 node_properties(self.__scheme) 652 653 log.debug("Modified strict check (modified flag: %s, " 654 "undo stack clean: %s, properties: %s)", 655 self.__modified, 656 self.__undoStack.isClean(), 657 propertiesChanged) 658 659 return self.isModified() or propertiesChanged 660 661 def uncleanProperties(self): 662 """ 663 Returns node properties differences since last clean state, 664 excluding unclean nodes. 665 """ 666 667 currentProperties = node_properties(self.__scheme) 668 # ignore diff for newly created nodes 669 cleanNodes = self.cleanNodes() 670 currentCleanNodeProperties = {k: v 671 for k, v in currentProperties.items() 672 if k in cleanNodes} 673 674 cleanProperties = self.__cleanProperties 675 # ignore diff for deleted nodes 676 currentNodes = self.__scheme.nodes 677 cleanCurrentNodeProperties = {k: v 678 for k, v in cleanProperties.items() 679 if k in currentNodes} 680 681 # ignore contexts 682 ignore = set((node, "context_settings") 683 for node in currentCleanNodeProperties.keys()) 684 685 return list(dictdiffer.diff( 686 cleanCurrentNodeProperties, 687 currentCleanNodeProperties, 688 ignore=ignore 689 )) 690 691 def restoreProperties(self, dict_diff): 692 ref_properties = { 693 node: node.properties for node in self.__scheme.nodes 694 } 695 dictdiffer.patch(dict_diff, ref_properties, in_place=True) 696 697 def cleanNodes(self): 698 return list(self.__cleanProperties.keys()) 699 700 def cleanLinks(self): 701 return self.__cleanLinks 702 703 def cleanAnnotations(self): 704 return self.__cleanAnnotations 705 706 def setQuickMenuTriggers(self, triggers): 707 # type: (int) -> None 708 """ 709 Set quick menu trigger flags. 710 711 Flags can be a bitwise `or` of: 712 713 - `SchemeEditWidget.NoTrigeres` 714 - `SchemeEditWidget.RightClicked` 715 - `SchemeEditWidget.DoubleClicked` 716 - `SchemeEditWidget.SpaceKey` 717 - `SchemeEditWidget.AnyKey` 718 719 """ 720 if self.__quickMenuTriggers != triggers: 721 self.__quickMenuTriggers = triggers 722 723 def quickMenuTriggers(self): 724 # type: () -> int 725 """ 726 Return quick menu trigger flags. 727 """ 728 return self.__quickMenuTriggers 729 730 def setChannelNamesVisible(self, visible): 731 # type: (bool) -> None 732 """ 733 Set channel names visibility state. When enabled the links 734 in the view will have a source/sink channel names displayed over 735 them. 736 """ 737 if self.__channelNamesVisible != visible: 738 self.__channelNamesVisible = visible 739 self.__scene.set_channel_names_visible(visible) 740 741 def channelNamesVisible(self): 742 # type: () -> bool 743 """ 744 Return the channel name visibility state. 745 """ 746 return self.__channelNamesVisible 747 748 def setNodeAnimationEnabled(self, enabled): 749 # type: (bool) -> None 750 """ 751 Set the node item animation enabled state. 752 """ 753 if self.__nodeAnimationEnabled != enabled: 754 self.__nodeAnimationEnabled = enabled 755 self.__scene.set_node_animation_enabled(enabled) 756 757 def nodeAnimationEnabled(self): 758 # type () -> bool 759 """ 760 Return the node item animation enabled state. 761 """ 762 return self.__nodeAnimationEnabled 763 764 def undoStack(self): 765 # type: () -> QUndoStack 766 """ 767 Return the undo stack. 768 """ 769 return self.__undoStack 770 771 def setPath(self, path): 772 # type: (str) -> None 773 """ 774 Set the path associated with the current scheme. 775 776 .. note:: Calling `setScheme` will invalidate the path (i.e. set it 777 to an empty string) 778 779 """ 780 if self.__path != path: 781 self.__path = path 782 self.pathChanged.emit(self.__path) 783 784 def path(self): 785 # type: () -> str 786 """ 787 Return the path associated with the scheme 788 """ 789 return self.__path 790 791 def setScheme(self, scheme): 792 # type: (Scheme) -> None 793 """ 794 Set the :class:`~.scheme.Scheme` instance to display/edit. 795 """ 796 if self.__scheme is not scheme: 797 if self.__scheme: 798 self.__scheme.title_changed.disconnect(self.titleChanged) 799 self.__scheme.window_group_presets_changed.disconnect( 800 self.__reset_window_group_menu 801 ) 802 self.__scheme.removeEventFilter(self) 803 sm = self.__scheme.findChild(signalmanager.SignalManager) 804 if sm: 805 sm.stateChanged.disconnect( 806 self.__signalManagerStateChanged) 807 self.__widgetManager = None 808 809 self.__scheme.node_added.disconnect(self.__statistics.log_node_add) 810 self.__scheme.node_removed.disconnect(self.__statistics.log_node_remove) 811 self.__scheme.link_added.disconnect(self.__statistics.log_link_add) 812 self.__scheme.link_removed.disconnect(self.__statistics.log_link_remove) 813 self.__statistics.write_statistics() 814 815 self.__scheme = scheme 816 self.__suggestions.set_scheme(self) 817 818 self.setPath("") 819 820 if self.__scheme: 821 self.__scheme.title_changed.connect(self.titleChanged) 822 self.titleChanged.emit(scheme.title) 823 self.__scheme.window_group_presets_changed.connect( 824 self.__reset_window_group_menu 825 ) 826 self.__cleanProperties = node_properties(scheme) 827 self.__cleanLinks = scheme.links 828 self.__cleanAnnotations = scheme.annotations 829 sm = scheme.findChild(signalmanager.SignalManager) 830 if sm: 831 sm.stateChanged.connect(self.__signalManagerStateChanged) 832 self.__widgetManager = getattr(scheme, "widget_manager", None) 833 834 self.__scheme.node_added.connect(self.__statistics.log_node_add) 835 self.__scheme.node_removed.connect(self.__statistics.log_node_remove) 836 self.__scheme.link_added.connect(self.__statistics.log_link_add) 837 self.__scheme.link_removed.connect(self.__statistics.log_link_remove) 838 self.__statistics.log_scheme(self.__scheme) 839 else: 840 self.__cleanProperties = {} 841 self.__cleanLinks = [] 842 self.__cleanAnnotations = [] 843 844 self.__teardownScene(self.__scene) 845 self.__scene.deleteLater() 846 847 self.__undoStack.clear() 848 849 self.__scene = CanvasScene(self) 850 self.__scene.setItemIndexMethod(CanvasScene.NoIndex) 851 self.__setupScene(self.__scene) 852 853 self.__scene.set_scheme(scheme) 854 self.__view.setScene(self.__scene) 855 856 if self.__scheme: 857 self.__scheme.installEventFilter(self) 858 nodes = self.__scheme.nodes 859 if nodes: 860 self.ensureVisible(nodes[0]) 861 self.__reset_window_group_menu() 862 863 def ensureVisible(self, node): 864 # type: (SchemeNode) -> None 865 """ 866 Scroll the contents of the viewport so that `node` is visible. 867 868 Parameters 869 ---------- 870 node: SchemeNode 871 """ 872 if self.__scheme is None: 873 return 874 item = self.__scene.item_for_node(node) 875 self.__view.ensureVisible(item) 876 877 def scheme(self): 878 # type: () -> Optional[Scheme] 879 """ 880 Return the :class:`~.scheme.Scheme` edited by the widget. 881 """ 882 return self.__scheme 883 884 def scene(self): 885 # type: () -> QGraphicsScene 886 """ 887 Return the :class:`QGraphicsScene` instance used to display the 888 current scheme. 889 """ 890 return self.__scene 891 892 def view(self): 893 # type: () -> QGraphicsView 894 """ 895 Return the :class:`QGraphicsView` instance used to display the 896 current scene. 897 """ 898 return self.__view 899 900 def suggestions(self): 901 """ 902 Return the widget suggestion prediction class. 903 """ 904 return self.__suggestions 905 906 def usageStatistics(self): 907 """ 908 Return the usage statistics logging class. 909 """ 910 return self.__statistics 911 912 def setRegistry(self, registry): 913 # Is this method necessary? 914 # It should be removed when the scene (items) is fixed 915 # so all information regarding the visual appearance is 916 # included in the node/widget description. 917 self.__registry = registry 918 if self.__scene: 919 self.__scene.set_registry(registry) 920 self.__quickMenu = None 921 922 def registry(self): 923 return self.__registry 924 925 def quickMenu(self): 926 # type: () -> quickmenu.QuickMenu 927 """ 928 Return a :class:`~.quickmenu.QuickMenu` popup menu instance for 929 new node creation. 930 """ 931 if self.__quickMenu is None: 932 menu = quickmenu.QuickMenu(self) 933 if self.__registry is not None: 934 menu.setModel(self.__registry.model()) 935 self.__quickMenu = menu 936 return self.__quickMenu 937 938 def setTitle(self, title): 939 # type: (str) -> None 940 """ 941 Set the scheme title. 942 """ 943 self.__undoStack.push( 944 commands.SetAttrCommand(self.__scheme, "title", title) 945 ) 946 947 def setDescription(self, description): 948 # type: (str) -> None 949 """ 950 Set the scheme description string. 951 """ 952 self.__undoStack.push( 953 commands.SetAttrCommand(self.__scheme, "description", description) 954 ) 955 956 def addNode(self, node): 957 # type: (SchemeNode) -> None 958 """ 959 Add a new node (:class:`.SchemeNode`) to the document. 960 """ 961 if self.__scheme is None: 962 raise NoWorkflowError() 963 command = commands.AddNodeCommand(self.__scheme, node) 964 self.__undoStack.push(command) 965 966 def createNewNode(self, description, title=None, position=None): 967 # type: (WidgetDescription, Optional[str], Optional[Pos]) -> SchemeNode 968 """ 969 Create a new :class:`.SchemeNode` and add it to the document. 970 The new node is constructed using :func:`~SchemeEdit.newNodeHelper` 971 method 972 """ 973 node = self.newNodeHelper(description, title, position) 974 self.addNode(node) 975 976 return node 977 978 def newNodeHelper(self, description, title=None, position=None): 979 # type: (WidgetDescription, Optional[str], Optional[Pos]) -> SchemeNode 980 """ 981 Return a new initialized :class:`.SchemeNode`. If `title` 982 and `position` are not supplied they are initialized to sensible 983 defaults. 984 """ 985 if title is None: 986 title = self.enumerateTitle(description.name) 987 988 if position is None: 989 position = self.nextPosition() 990 991 return SchemeNode(description, title=title, position=position) 992 993 def enumerateTitle(self, title): 994 # type: (str) -> str 995 """ 996 Enumerate a `title` string (i.e. add a number in parentheses) so 997 it is not equal to any node title in the current scheme. 998 """ 999 if self.__scheme is None: 1000 return title 1001 curr_titles = set([node.title for node in self.__scheme.nodes]) 1002 template = title + " ({0})" 1003 1004 enumerated = (template.format(i) for i in itertools.count(1)) 1005 candidates = itertools.chain([title], enumerated) 1006 1007 seq = itertools.dropwhile(curr_titles.__contains__, candidates) 1008 return next(seq) 1009 1010 def nextPosition(self): 1011 # type: () -> Tuple[float, float] 1012 """ 1013 Return the next default node position as a (x, y) tuple. This is 1014 a position left of the last added node. 1015 """ 1016 if self.__scheme is not None: 1017 nodes = self.__scheme.nodes 1018 else: 1019 nodes = [] 1020 if nodes: 1021 x, y = nodes[-1].position 1022 position = (x + 150, y) 1023 else: 1024 position = (150, 150) 1025 return position 1026 1027 def removeNode(self, node): 1028 # type: (SchemeNode) -> None 1029 """ 1030 Remove a `node` (:class:`.SchemeNode`) from the scheme 1031 """ 1032 if self.__scheme is None: 1033 raise NoWorkflowError() 1034 command = commands.RemoveNodeCommand(self.__scheme, node) 1035 self.__undoStack.push(command) 1036 1037 def renameNode(self, node, title): 1038 # type: (SchemeNode, str) -> None 1039 """ 1040 Rename a `node` (:class:`.SchemeNode`) to `title`. 1041 """ 1042 if self.__scheme is None: 1043 raise NoWorkflowError() 1044 self.__undoStack.push( 1045 commands.RenameNodeCommand(self.__scheme, node, node.title, title) 1046 ) 1047 1048 def addLink(self, link): 1049 # type: (SchemeLink) -> None 1050 """ 1051 Add a `link` (:class:`.SchemeLink`) to the scheme. 1052 """ 1053 if self.__scheme is None: 1054 raise NoWorkflowError() 1055 command = commands.AddLinkCommand(self.__scheme, link) 1056 self.__undoStack.push(command) 1057 1058 def removeLink(self, link): 1059 # type: (SchemeLink) -> None 1060 """ 1061 Remove a link (:class:`.SchemeLink`) from the scheme. 1062 """ 1063 if self.__scheme is None: 1064 raise NoWorkflowError() 1065 command = commands.RemoveLinkCommand(self.__scheme, link) 1066 self.__undoStack.push(command) 1067 1068 def insertNode(self, new_node, old_link): 1069 # type: (SchemeNode, SchemeLink) -> None 1070 """ 1071 Insert a node in-between two linked nodes. 1072 """ 1073 if self.__scheme is None: 1074 raise NoWorkflowError() 1075 source_node = old_link.source_node 1076 sink_node = old_link.sink_node 1077 source_channel = old_link.source_channel 1078 sink_channel = old_link.sink_channel 1079 1080 proposed_links = (self.__scheme.propose_links(source_node, new_node), 1081 self.__scheme.propose_links(new_node, sink_node)) 1082 # Preserve existing {source,sink}_channel if possible; use first 1083 # proposed if not. 1084 first = findf(proposed_links[0], lambda t: t[0] == source_channel, 1085 default=proposed_links[0][0]) 1086 second = findf(proposed_links[1], lambda t: t[1] == sink_channel, 1087 default=proposed_links[1][0]) 1088 new_links = ( 1089 SchemeLink(source_node, first[0], new_node, first[1]), 1090 SchemeLink(new_node, second[0], sink_node, second[1]) 1091 ) 1092 command = commands.InsertNodeCommand(self.__scheme, new_node, old_link, new_links) 1093 self.__undoStack.push(command) 1094 1095 def onNewLink(self, func): 1096 """ 1097 Runs function when new link is added to current scheme. 1098 """ 1099 self.__scheme.link_added.connect(func) 1100 1101 def addAnnotation(self, annotation): 1102 # type: (BaseSchemeAnnotation) -> None 1103 """ 1104 Add `annotation` (:class:`.BaseSchemeAnnotation`) to the scheme 1105 """ 1106 if self.__scheme is None: 1107 raise NoWorkflowError() 1108 command = commands.AddAnnotationCommand(self.__scheme, annotation) 1109 self.__undoStack.push(command) 1110 1111 def removeAnnotation(self, annotation): 1112 # type: (BaseSchemeAnnotation) -> None 1113 """ 1114 Remove `annotation` (:class:`.BaseSchemeAnnotation`) from the scheme. 1115 """ 1116 if self.__scheme is None: 1117 raise NoWorkflowError() 1118 command = commands.RemoveAnnotationCommand(self.__scheme, annotation) 1119 self.__undoStack.push(command) 1120 1121 def removeSelected(self): 1122 # type: () -> None 1123 """ 1124 Remove all selected items in the scheme. 1125 """ 1126 selected = self.scene().selectedItems() 1127 if not selected: 1128 return 1129 scene = self.scene() 1130 self.__undoStack.beginMacro(self.tr("Remove")) 1131 # order LinkItem removes before NodeItems; Removing NodeItems also 1132 # removes links so some links in selected could already be removed by 1133 # a preceding NodeItem remove 1134 selected = sorted( 1135 selected, key=lambda item: not isinstance(item, items.LinkItem)) 1136 for item in selected: 1137 assert self.__scheme is not None 1138 if isinstance(item, items.NodeItem): 1139 node = scene.node_for_item(item) 1140 self.__undoStack.push( 1141 commands.RemoveNodeCommand(self.__scheme, node) 1142 ) 1143 elif isinstance(item, items.annotationitem.Annotation): 1144 if item.hasFocus() or item.isAncestorOf(scene.focusItem()): 1145 # Clear input focus from the item to be removed. 1146 scene.focusItem().clearFocus() 1147 annot = scene.annotation_for_item(item) 1148 self.__undoStack.push( 1149 commands.RemoveAnnotationCommand(self.__scheme, annot) 1150 ) 1151 elif isinstance(item, items.LinkItem): 1152 link = scene.link_for_item(item) 1153 self.__undoStack.push( 1154 commands.RemoveLinkCommand(self.__scheme, link) 1155 ) 1156 self.__undoStack.endMacro() 1157 1158 def selectAll(self): 1159 # type: () -> None 1160 """ 1161 Select all selectable items in the scheme. 1162 """ 1163 for item in self.__scene.items(): 1164 if item.flags() & QGraphicsItem.ItemIsSelectable: 1165 item.setSelected(True) 1166 1167 def alignToGrid(self): 1168 # type: () -> None 1169 """ 1170 Align nodes to a grid. 1171 """ 1172 # TODO: The the current layout implementation is BAD (fix is urgent). 1173 if self.__scheme is None: 1174 return 1175 1176 tile_size = 150 1177 tiles = {} # type: Dict[Tuple[int, int], SchemeNode] 1178 nodes = sorted(self.__scheme.nodes, key=attrgetter("position")) 1179 1180 if nodes: 1181 self.__undoStack.beginMacro(self.tr("Align To Grid")) 1182 1183 for node in nodes: 1184 x, y = node.position 1185 x = int(round(float(x) / tile_size) * tile_size) 1186 y = int(round(float(y) / tile_size) * tile_size) 1187 while (x, y) in tiles: 1188 x += tile_size 1189 1190 self.__undoStack.push( 1191 commands.MoveNodeCommand(self.__scheme, node, 1192 node.position, (x, y)) 1193 ) 1194 1195 tiles[x, y] = node 1196 self.__scene.item_for_node(node).setPos(x, y) 1197 1198 self.__undoStack.endMacro() 1199 1200 def focusNode(self): 1201 # type: () -> Optional[SchemeNode] 1202 """ 1203 Return the current focused :class:`.SchemeNode` or ``None`` if no 1204 node has focus. 1205 """ 1206 focus = self.__scene.focusItem() 1207 node = None 1208 if isinstance(focus, items.NodeItem): 1209 try: 1210 node = self.__scene.node_for_item(focus) 1211 except KeyError: 1212 # in case the node has been removed but the scene was not 1213 # yet fully updated. 1214 node = None 1215 return node 1216 1217 def selectedNodes(self): 1218 # type: () -> List[SchemeNode] 1219 """ 1220 Return all selected :class:`.SchemeNode` items. 1221 """ 1222 return list(map(self.scene().node_for_item, 1223 self.scene().selected_node_items())) 1224 1225 def selectedLinks(self): 1226 # type: () -> List[SchemeLink] 1227 return list(map(self.scene().link_for_item, 1228 self.scene().selected_link_items())) 1229 1230 def selectedAnnotations(self): 1231 # type: () -> List[BaseSchemeAnnotation] 1232 """ 1233 Return all selected :class:`.BaseSchemeAnnotation` items. 1234 """ 1235 return list(map(self.scene().annotation_for_item, 1236 self.scene().selected_annotation_items())) 1237 1238 def openSelected(self): 1239 # type: () -> None 1240 """ 1241 Open (show and raise) all widgets for the current selected nodes. 1242 """ 1243 selected = self.selectedNodes() 1244 for node in selected: 1245 QCoreApplication.sendEvent( 1246 node, WorkflowEvent(WorkflowEvent.NodeActivateRequest)) 1247 1248 def editNodeTitle(self, node): 1249 # type: (SchemeNode) -> None 1250 """ 1251 Edit (rename) the `node`'s title. 1252 """ 1253 self.__view.setFocus(Qt.OtherFocusReason) 1254 scene = self.__scene 1255 item = scene.item_for_node(node) 1256 item.editTitle() 1257 1258 def commit(): 1259 name = item.title() 1260 if name == node.title: 1261 return # pragma: no cover 1262 self.__undoStack.push( 1263 commands.RenameNodeCommand(self.__scheme, node, node.title, 1264 name) 1265 ) 1266 connect_with_context( 1267 item.titleEditingFinished, self, commit 1268 ) 1269 1270 def __onCleanChanged(self, clean): 1271 # type: (bool) -> None 1272 if self.isWindowModified() != (not clean): 1273 self.setWindowModified(not clean) 1274 self.modificationChanged.emit(not clean) 1275 1276 def setDropHandlers(self, dropHandlers: Sequence[DropHandler]) -> None: 1277 """ 1278 Set handlers for drop events onto the workflow view. 1279 """ 1280 self.__dropHandlers = tuple(dropHandlers) 1281 1282 def changeEvent(self, event): 1283 # type: (QEvent) -> None 1284 if event.type() == QEvent.FontChange: 1285 self.__updateFont() 1286 elif event.type() == QEvent.PaletteChange: 1287 if self.__scene is not None: 1288 self.__scene.setPalette(self.palette()) 1289 1290 super().changeEvent(event) 1291 1292 def __lookup_registry(self, qname: str) -> Optional[WidgetDescription]: 1293 if self.__registry is not None: 1294 try: 1295 return self.__registry.widget(qname) 1296 except KeyError: 1297 pass 1298 return None 1299 1300 def __desc_from_mime_data(self, data: QMimeData) -> Optional[WidgetDescription]: 1301 MIME_TYPES = [ 1302 "application/vnd.orange-canvas.registry.qualified-name", 1303 # A back compatible misspelling 1304 "application/vnv.orange-canvas.registry.qualified-name", 1305 ] 1306 for typ in MIME_TYPES: 1307 if data.hasFormat(typ): 1308 qname_bytes = bytes(data.data(typ).data()) 1309 try: 1310 qname = qname_bytes.decode("utf-8") 1311 except UnicodeDecodeError: 1312 return None 1313 return self.__lookup_registry(qname) 1314 return None 1315 1316 def eventFilter(self, obj, event): 1317 # type: (QObject, QEvent) -> bool 1318 # Filter the scene's drag/drop events. 1319 if obj is self.scene(): 1320 etype = event.type() 1321 if etype == QEvent.GraphicsSceneDragEnter or \ 1322 etype == QEvent.GraphicsSceneDragMove: 1323 assert isinstance(event, QGraphicsSceneDragDropEvent) 1324 drop_target = None 1325 desc = self.__desc_from_mime_data(event.mimeData()) 1326 if desc is not None: 1327 item = self.__scene.item_at(event.scenePos(), items.LinkItem) 1328 link = self.scene().link_for_item(item) if item else None 1329 if link is not None and can_insert_node(desc, link): 1330 drop_target = item 1331 drop_target.setHoverState(True) 1332 event.acceptProposedAction() 1333 if self.__dropTarget is not None and \ 1334 self.__dropTarget is not drop_target: 1335 self.__dropTarget.setHoverState(False) 1336 self.__dropTarget = drop_target 1337 if desc is not None: 1338 return True 1339 elif etype == QEvent.GraphicsSceneDragLeave: 1340 if self.__dropTarget is not None: 1341 self.__dropTarget.setHoverState(False) 1342 self.__dropTarget = None 1343 elif etype == QEvent.GraphicsSceneDrop: 1344 assert isinstance(event, QGraphicsSceneDragDropEvent) 1345 desc = self.__desc_from_mime_data(event.mimeData()) 1346 if desc is not None: 1347 statistics = self.usageStatistics() 1348 pos = event.scenePos() 1349 item = self.__scene.item_at(event.scenePos(), items.LinkItem) 1350 link = self.scene().link_for_item(item) if item else None 1351 if link and can_insert_node(desc, link): 1352 statistics.begin_insert_action(True, link) 1353 node = self.newNodeHelper(desc, position=(pos.x(), pos.y())) 1354 self.insertNode(node, link) 1355 else: 1356 statistics.begin_action(UsageStatistics.ToolboxDrag) 1357 self.createNewNode(desc, position=(pos.x(), pos.y())) 1358 return True 1359 1360 if etype == QEvent.GraphicsSceneDragEnter: 1361 return self.sceneDragEnterEvent(event) 1362 elif etype == QEvent.GraphicsSceneDragMove: 1363 return self.sceneDragMoveEvent(event) 1364 elif etype == QEvent.GraphicsSceneDragLeave: 1365 return self.sceneDragLeaveEvent(event) 1366 elif etype == QEvent.GraphicsSceneDrop: 1367 return self.sceneDropEvent(event) 1368 elif etype == QEvent.GraphicsSceneMousePress: 1369 self.__pasteOrigin = event.scenePos() 1370 return self.sceneMousePressEvent(event) 1371 elif etype == QEvent.GraphicsSceneMouseMove: 1372 return self.sceneMouseMoveEvent(event) 1373 elif etype == QEvent.GraphicsSceneMouseRelease: 1374 return self.sceneMouseReleaseEvent(event) 1375 elif etype == QEvent.GraphicsSceneMouseDoubleClick: 1376 return self.sceneMouseDoubleClickEvent(event) 1377 elif etype == QEvent.KeyPress: 1378 return self.sceneKeyPressEvent(event) 1379 elif etype == QEvent.KeyRelease: 1380 return self.sceneKeyReleaseEvent(event) 1381 elif etype == QEvent.GraphicsSceneContextMenu: 1382 return self.sceneContextMenuEvent(event) 1383 1384 elif obj is self.__scheme: 1385 if event.type() == QEvent.WhatsThisClicked: 1386 # Re post the event 1387 self.__showHelpFor(event.href()) 1388 elif event.type() == WorkflowEvent.ActivateParentRequest: 1389 self.window().activateWindow() 1390 self.window().raise_() 1391 1392 return super().eventFilter(obj, event) 1393 1394 def sceneMousePressEvent(self, event): 1395 # type: (QGraphicsSceneMouseEvent) -> bool 1396 scene = self.__scene 1397 if scene.user_interaction_handler: 1398 return False 1399 1400 pos = event.scenePos() 1401 1402 anchor_item = scene.item_at( 1403 pos, items.NodeAnchorItem, buttons=Qt.LeftButton) 1404 if anchor_item and event.button() == Qt.LeftButton: 1405 # Start a new link starting at item 1406 scene.clearSelection() 1407 handler = interactions.NewLinkAction(self) 1408 self._setUserInteractionHandler(handler) 1409 return handler.mousePressEvent(event) 1410 1411 link_item = scene.item_at(pos, items.LinkItem) 1412 if link_item and event.button() == Qt.MiddleButton: 1413 link = self.scene().link_for_item(link_item) 1414 self.removeLink(link) 1415 event.accept() 1416 return True 1417 any_item = scene.item_at(pos) 1418 # start node name edit on selected clicked 1419 if sys.platform == "darwin" \ 1420 and event.button() == Qt.LeftButton \ 1421 and isinstance(any_item, items.nodeitem.GraphicsTextEdit) \ 1422 and isinstance(any_item.parentItem(), items.NodeItem): 1423 node = scene.node_for_item(any_item.parentItem()) 1424 selected = self.selectedNodes() 1425 if node in selected: 1426 # deselect all other elements except the node item 1427 # and start the edit 1428 for selected_node in selected: 1429 selected_node_item = scene.item_for_node(selected_node) 1430 selected_node_item.setSelected(selected_node is node) 1431 self.editNodeTitle(node) 1432 return True 1433 1434 if not any_item: 1435 self.__emptyClickButtons |= event.button() 1436 1437 if not any_item and event.button() == Qt.LeftButton: 1438 # Create a RectangleSelectionAction but do not set in on the scene 1439 # just yet (instead wait for the mouse move event). 1440 handler = interactions.RectangleSelectionAction(self) 1441 rval = handler.mousePressEvent(event) 1442 if rval is True: 1443 self.__possibleSelectionHandler = handler 1444 return rval 1445 1446 if any_item and event.button() == Qt.LeftButton: 1447 self.__possibleMouseItemsMove = True 1448 self.__itemsMoving.clear() 1449 self.__scene.node_item_position_changed.connect( 1450 self.__onNodePositionChanged 1451 ) 1452 self.__annotationGeomChanged.mapped[QObject].connect( 1453 self.__onAnnotationGeometryChanged 1454 ) 1455 1456 set_enabled_all(self.__disruptiveActions, False) 1457 1458 return False 1459 1460 def sceneMouseMoveEvent(self, event): 1461 # type: (QGraphicsSceneMouseEvent) -> bool 1462 scene = self.__scene 1463 if scene.user_interaction_handler: 1464 return False 1465 1466 if self.__emptyClickButtons & Qt.LeftButton and \ 1467 event.buttons() & Qt.LeftButton and \ 1468 self.__possibleSelectionHandler: 1469 # Set the RectangleSelection (initialized in mousePressEvent) 1470 # on the scene 1471 handler = self.__possibleSelectionHandler 1472 self._setUserInteractionHandler(handler) 1473 self.__possibleSelectionHandler = None 1474 return handler.mouseMoveEvent(event) 1475 1476 return False 1477 1478 def sceneMouseReleaseEvent(self, event): 1479 # type: (QGraphicsSceneMouseEvent) -> bool 1480 scene = self.__scene 1481 if scene.user_interaction_handler: 1482 return False 1483 1484 if event.button() == Qt.LeftButton and self.__possibleMouseItemsMove: 1485 self.__possibleMouseItemsMove = False 1486 self.__scene.node_item_position_changed.disconnect( 1487 self.__onNodePositionChanged 1488 ) 1489 self.__annotationGeomChanged.mapped[QObject].disconnect( 1490 self.__onAnnotationGeometryChanged 1491 ) 1492 1493 set_enabled_all(self.__disruptiveActions, True) 1494 1495 if self.__itemsMoving: 1496 self.__scene.mouseReleaseEvent(event) 1497 scheme = self.__scheme 1498 assert scheme is not None 1499 stack = self.undoStack() 1500 stack.beginMacro(self.tr("Move")) 1501 for scheme_item, (old, new) in self.__itemsMoving.items(): 1502 if isinstance(scheme_item, SchemeNode): 1503 command = commands.MoveNodeCommand( 1504 scheme, scheme_item, old, new 1505 ) 1506 elif isinstance(scheme_item, BaseSchemeAnnotation): 1507 command = commands.AnnotationGeometryChange( 1508 scheme, scheme_item, old, new 1509 ) 1510 else: 1511 continue 1512 1513 stack.push(command) 1514 stack.endMacro() 1515 1516 self.__itemsMoving.clear() 1517 return True 1518 elif event.button() == Qt.LeftButton: 1519 self.__possibleSelectionHandler = None 1520 1521 return False 1522 1523 def sceneMouseDoubleClickEvent(self, event): 1524 # type: (QGraphicsSceneMouseEvent) -> bool 1525 scene = self.__scene 1526 if scene.user_interaction_handler: 1527 return False 1528 1529 item = scene.item_at(event.scenePos()) 1530 if not item and self.__quickMenuTriggers & \ 1531 SchemeEditWidget.DoubleClicked: 1532 # Double click on an empty spot 1533 # Create a new node using QuickMenu 1534 action = interactions.NewNodeAction(self) 1535 with disable_undo_stack_actions( 1536 self.__undoAction, self.__redoAction, self.__undoStack): 1537 action.create_new(event.screenPos()) 1538 1539 event.accept() 1540 return True 1541 1542 return False 1543 1544 def sceneKeyPressEvent(self, event): 1545 # type: (QKeyEvent) -> bool 1546 self.__updateOpenWidgetAnchors(event) 1547 1548 scene = self.__scene 1549 if scene.user_interaction_handler: 1550 return False 1551 1552 # If a QGraphicsItem is in text editing mode, don't interrupt it 1553 focusItem = scene.focusItem() 1554 if focusItem and isinstance(focusItem, QGraphicsTextItem) and \ 1555 focusItem.textInteractionFlags() & Qt.TextEditable: 1556 return False 1557 1558 # If the mouse is not over out view 1559 if not self.view().underMouse(): 1560 return False 1561 1562 handler = None 1563 searchText = "" 1564 if (event.key() == Qt.Key_Space and \ 1565 self.__quickMenuTriggers & SchemeEditWidget.SpaceKey): 1566 handler = interactions.NewNodeAction(self) 1567 1568 elif len(event.text()) and \ 1569 self.__quickMenuTriggers & SchemeEditWidget.AnyKey and \ 1570 is_printable(event.text()[0]): 1571 handler = interactions.NewNodeAction(self) 1572 searchText = event.text() 1573 1574 if handler is not None: 1575 # Control + Backspace (remove widget action on Mac OSX) conflicts 1576 # with the 'Clear text' action in the search widget (there might 1577 # be selected items in the canvas), so we disable the 1578 # remove widget action so the text editing follows standard 1579 # 'look and feel' 1580 with ExitStack() as stack: 1581 stack.enter_context(disabled(self.__removeSelectedAction)) 1582 stack.enter_context( 1583 disable_undo_stack_actions( 1584 self.__undoAction, self.__redoAction, self.__undoStack) 1585 ) 1586 handler.create_new(QCursor.pos(), searchText) 1587 1588 event.accept() 1589 return True 1590 1591 return False 1592 1593 def sceneKeyReleaseEvent(self, event): 1594 # type: (QKeyEvent) -> bool 1595 self.__updateOpenWidgetAnchors(event) 1596 return False 1597 1598 def __updateOpenWidgetAnchors(self, event=None): 1599 scene = self.__scene 1600 # Open widget anchors on shift. New link action should work during this 1601 if event: 1602 open = event.modifiers() == Qt.ShiftModifier 1603 else: 1604 open = QApplication.keyboardModifiers() == Qt.ShiftModifier 1605 scene.set_widget_anchors_open(open) 1606 1607 def sceneContextMenuEvent(self, event): 1608 # type: (QGraphicsSceneContextMenuEvent) -> bool 1609 scenePos = event.scenePos() 1610 globalPos = event.screenPos() 1611 1612 item = self.scene().item_at(scenePos, items.NodeItem) 1613 if item is not None: 1614 node = self.scene().node_for_item(item) # type: SchemeNode 1615 actions = [] # type: List[QAction] 1616 manager = self.widgetManager() 1617 if manager is not None: 1618 actions = manager.actions_for_context_menu(node) 1619 1620 # TODO: Inspect actions for all selected nodes and merge 'same' 1621 # actions (by name) 1622 if actions and len(self.selectedNodes()) == 1: 1623 # The node has extra actions for the context menu. 1624 # Copy the default context menu and append the extra actions. 1625 menu = QMenu(self) 1626 for a in self.__widgetMenu.actions(): 1627 menu.addAction(a) 1628 menu.addSeparator() 1629 for a in actions: 1630 menu.addAction(a) 1631 menu.setAttribute(Qt.WA_DeleteOnClose) 1632 else: 1633 menu = self.__widgetMenu 1634 menu.popup(globalPos) 1635 return True 1636 1637 item = self.scene().item_at(scenePos, items.LinkItem) 1638 if item is not None: 1639 link = self.scene().link_for_item(item) 1640 self.__linkEnableAction.setChecked(link.enabled) 1641 self.__contextMenuTarget = link 1642 self.__linkMenu.popup(globalPos) 1643 return True 1644 1645 item = self.scene().item_at(scenePos) 1646 if not item and \ 1647 self.__quickMenuTriggers & SchemeEditWidget.RightClicked: 1648 action = interactions.NewNodeAction(self) 1649 1650 with disable_undo_stack_actions( 1651 self.__undoAction, self.__redoAction, self.__undoStack): 1652 action.create_new(globalPos) 1653 return True 1654 1655 return False 1656 1657 def sceneDragEnterEvent(self, event: QGraphicsSceneDragDropEvent) -> bool: 1658 UNUSED(event) 1659 delegate = self._userInteractionHandler() 1660 if delegate is not None: 1661 return False 1662 1663 handler = interactions.DropAction(self, dropHandlers=self.__dropHandlers) 1664 self._setUserInteractionHandler(handler) 1665 return False 1666 1667 def sceneDragMoveEvent(self, event: QGraphicsSceneDragDropEvent) -> bool: 1668 UNUSED(event) 1669 return False 1670 1671 def sceneDragLeaveEvent(self, event: QGraphicsSceneDragDropEvent) -> bool: 1672 UNUSED(event) 1673 return False 1674 1675 def sceneDropEvent(self, event: QGraphicsSceneDragDropEvent) -> bool: 1676 UNUSED(event) 1677 return False 1678 1679 def _userInteractionHandler(self): 1680 return self.__scene.user_interaction_handler 1681 1682 def _setUserInteractionHandler(self, handler): 1683 # type: (Optional[interactions.UserInteraction]) -> None 1684 """ 1685 Helper method for setting the user interaction handlers. 1686 """ 1687 if self.__scene.user_interaction_handler: 1688 if isinstance(self.__scene.user_interaction_handler, 1689 (interactions.ResizeArrowAnnotation, 1690 interactions.ResizeTextAnnotation)): 1691 self.__scene.user_interaction_handler.commit() 1692 1693 self.__scene.user_interaction_handler.ended.disconnect( 1694 self.__onInteractionEnded 1695 ) 1696 1697 if handler: 1698 handler.ended.connect(self.__onInteractionEnded) 1699 # Disable actions which could change the model 1700 set_enabled_all(self.__disruptiveActions, False) 1701 1702 self.__scene.set_user_interaction_handler(handler) 1703 1704 def __onInteractionEnded(self): 1705 # type: () -> None 1706 self.sender().ended.disconnect(self.__onInteractionEnded) 1707 set_enabled_all(self.__disruptiveActions, True) 1708 self.__updateOpenWidgetAnchors() 1709 1710 def __onSelectionChanged(self): 1711 # type: () -> None 1712 nodes = self.selectedNodes() 1713 annotations = self.selectedAnnotations() 1714 links = self.selectedLinks() 1715 1716 self.__renameAction.setEnabled(len(nodes) == 1) 1717 self.__openSelectedAction.setEnabled(bool(nodes)) 1718 self.__removeSelectedAction.setEnabled( 1719 bool(nodes or annotations or links) 1720 ) 1721 1722 self.__helpAction.setEnabled(len(nodes) == 1) 1723 self.__renameAction.setEnabled(len(nodes) == 1) 1724 self.__duplicateSelectedAction.setEnabled(bool(nodes)) 1725 self.__copySelectedAction.setEnabled(bool(nodes)) 1726 1727 if len(nodes) > 1: 1728 self.__openSelectedAction.setText(self.tr("Open All")) 1729 else: 1730 self.__openSelectedAction.setText(self.tr("Open")) 1731 1732 if len(nodes) + len(annotations) + len(links) > 1: 1733 self.__removeSelectedAction.setText(self.tr("Remove All")) 1734 else: 1735 self.__removeSelectedAction.setText(self.tr("Remove")) 1736 1737 focus = self.focusNode() 1738 if focus is not None: 1739 desc = focus.description 1740 tip = whats_this_helper(desc, include_more_link=True) 1741 else: 1742 tip = "" 1743 1744 if tip != self.__quickTip: 1745 self.__quickTip = tip 1746 ev = QuickHelpTipEvent("", self.__quickTip, 1747 priority=QuickHelpTipEvent.Permanent) 1748 1749 QCoreApplication.sendEvent(self, ev) 1750 1751 def __onLinkActivate(self, item): 1752 link = self.scene().link_for_item(item) 1753 action = interactions.EditNodeLinksAction(self, link.source_node, 1754 link.sink_node) 1755 action.edit_links() 1756 1757 def __onLinkAdded(self, item: items.LinkItem) -> None: 1758 item.setFlag(QGraphicsItem.ItemIsSelectable) 1759 1760 def __onNodeActivate(self, item): 1761 # type: (items.NodeItem) -> None 1762 node = self.__scene.node_for_item(item) 1763 QCoreApplication.sendEvent( 1764 node, WorkflowEvent(WorkflowEvent.NodeActivateRequest)) 1765 1766 def __onNodePositionChanged(self, item, pos): 1767 # type: (items.NodeItem, QPointF) -> None 1768 node = self.__scene.node_for_item(item) 1769 new = (pos.x(), pos.y()) 1770 if node not in self.__itemsMoving: 1771 self.__itemsMoving[node] = (node.position, new) 1772 else: 1773 old, _ = self.__itemsMoving[node] 1774 self.__itemsMoving[node] = (old, new) 1775 1776 def __onAnnotationGeometryChanged(self, item): 1777 # type: (AnnotationItem) -> None 1778 annot = self.scene().annotation_for_item(item) 1779 if annot not in self.__itemsMoving: 1780 self.__itemsMoving[annot] = (annot.geometry, 1781 geometry_from_annotation_item(item)) 1782 else: 1783 old, _ = self.__itemsMoving[annot] 1784 self.__itemsMoving[annot] = (old, 1785 geometry_from_annotation_item(item)) 1786 1787 def __onAnnotationAdded(self, item): 1788 # type: (AnnotationItem) -> None 1789 log.debug("Annotation added (%r)", item) 1790 item.setFlag(QGraphicsItem.ItemIsSelectable) 1791 item.setFlag(QGraphicsItem.ItemIsMovable) 1792 item.setFlag(QGraphicsItem.ItemIsFocusable) 1793 1794 if isinstance(item, items.ArrowAnnotation): 1795 pass 1796 elif isinstance(item, items.TextAnnotation): 1797 # Make the annotation editable. 1798 item.setTextInteractionFlags(Qt.TextEditorInteraction) 1799 1800 self.__editFinishedMapper.setMapping(item, item) 1801 item.editingFinished.connect( 1802 self.__editFinishedMapper.map 1803 ) 1804 1805 self.__annotationGeomChanged.setMapping(item, item) 1806 item.geometryChanged.connect( 1807 self.__annotationGeomChanged.map 1808 ) 1809 1810 def __onAnnotationRemoved(self, item): 1811 # type: (AnnotationItem) -> None 1812 log.debug("Annotation removed (%r)", item) 1813 if isinstance(item, items.ArrowAnnotation): 1814 pass 1815 elif isinstance(item, items.TextAnnotation): 1816 item.editingFinished.disconnect( 1817 self.__editFinishedMapper.map 1818 ) 1819 1820 self.__annotationGeomChanged.removeMappings(item) 1821 item.geometryChanged.disconnect( 1822 self.__annotationGeomChanged.map 1823 ) 1824 1825 def __onFocusItemChanged(self, newFocusItem, oldFocusItem): 1826 # type: (Optional[QGraphicsItem], Optional[QGraphicsItem]) -> None 1827 1828 if isinstance(oldFocusItem, items.annotationitem.Annotation): 1829 self.__endControlPointEdit() 1830 if isinstance(newFocusItem, items.annotationitem.Annotation): 1831 if not self.__scene.user_interaction_handler: 1832 self.__startControlPointEdit(newFocusItem) 1833 1834 def __onEditingFinished(self, item): 1835 # type: (items.TextAnnotation) -> None 1836 """ 1837 Text annotation editing has finished. 1838 """ 1839 annot = self.__scene.annotation_for_item(item) 1840 assert isinstance(annot, SchemeTextAnnotation) 1841 content_type = item.contentType() 1842 content = item.content() 1843 1844 if annot.text != content or annot.content_type != content_type: 1845 assert self.__scheme is not None 1846 self.__undoStack.push( 1847 commands.TextChangeCommand( 1848 self.__scheme, annot, 1849 annot.text, annot.content_type, 1850 content, content_type 1851 ) 1852 ) 1853 1854 def __toggleNewArrowAnnotation(self, checked): 1855 # type: (bool) -> None 1856 if self.__newTextAnnotationAction.isChecked(): 1857 # Uncheck the text annotation action if needed. 1858 self.__newTextAnnotationAction.setChecked(not checked) 1859 1860 action = self.__newArrowAnnotationAction 1861 1862 if not checked: 1863 # The action was unchecked (canceled by the user) 1864 handler = self.__scene.user_interaction_handler 1865 if isinstance(handler, interactions.NewArrowAnnotation): 1866 # Cancel the interaction and restore the state 1867 handler.ended.disconnect(action.toggle) 1868 handler.cancel(interactions.UserInteraction.UserCancelReason) 1869 log.info("Canceled new arrow annotation") 1870 1871 else: 1872 handler = interactions.NewArrowAnnotation(self) 1873 checked_action = self.__arrowColorActionGroup.checkedAction() 1874 handler.setColor(checked_action.data()) 1875 1876 handler.ended.connect(action.toggle) 1877 1878 self._setUserInteractionHandler(handler) 1879 1880 def __onFontSizeTriggered(self, action): 1881 # type: (QAction) -> None 1882 if not self.__newTextAnnotationAction.isChecked(): 1883 # When selecting from the (font size) menu the 'Text' 1884 # action does not get triggered automatically. 1885 self.__newTextAnnotationAction.trigger() 1886 else: 1887 # Update the preferred font on the interaction handler. 1888 handler = self.__scene.user_interaction_handler 1889 if isinstance(handler, interactions.NewTextAnnotation): 1890 handler.setFont(action.font()) 1891 1892 def __toggleNewTextAnnotation(self, checked): 1893 # type: (bool) -> None 1894 if self.__newArrowAnnotationAction.isChecked(): 1895 # Uncheck the arrow annotation if needed. 1896 self.__newArrowAnnotationAction.setChecked(not checked) 1897 1898 action = self.__newTextAnnotationAction 1899 1900 if not checked: 1901 # The action was unchecked (canceled by the user) 1902 handler = self.__scene.user_interaction_handler 1903 if isinstance(handler, interactions.NewTextAnnotation): 1904 # cancel the interaction and restore the state 1905 handler.ended.disconnect(action.toggle) 1906 handler.cancel(interactions.UserInteraction.UserCancelReason) 1907 log.info("Canceled new text annotation") 1908 1909 else: 1910 handler = interactions.NewTextAnnotation(self) 1911 checked_action = self.__fontActionGroup.checkedAction() 1912 handler.setFont(checked_action.font()) 1913 1914 handler.ended.connect(action.toggle) 1915 1916 self._setUserInteractionHandler(handler) 1917 1918 def __onArrowColorTriggered(self, action): 1919 # type: (QAction) -> None 1920 if not self.__newArrowAnnotationAction.isChecked(): 1921 # When selecting from the (color) menu the 'Arrow' 1922 # action does not get triggered automatically. 1923 self.__newArrowAnnotationAction.trigger() 1924 else: 1925 # Update the preferred color on the interaction handler 1926 handler = self.__scene.user_interaction_handler 1927 if isinstance(handler, interactions.NewArrowAnnotation): 1928 handler.setColor(action.data()) 1929 1930 def __onRenameAction(self): 1931 # type: () -> None 1932 """ 1933 Rename was requested for the selected widget. 1934 """ 1935 selected = self.selectedNodes() 1936 if len(selected) == 1: 1937 self.editNodeTitle(selected[0]) 1938 1939 def __onHelpAction(self): 1940 # type: () -> None 1941 """ 1942 Help was requested for the selected widget. 1943 """ 1944 nodes = self.selectedNodes() 1945 help_url = None 1946 if len(nodes) == 1: 1947 node = nodes[0] 1948 desc = node.description 1949 1950 help_url = "help://search?" + urlencode({"id": desc.qualified_name}) 1951 self.__showHelpFor(help_url) 1952 1953 def __showHelpFor(self, help_url): 1954 # type: (str) -> None 1955 """ 1956 Show help for an "help" url. 1957 """ 1958 # Notify the parent chain and let them respond 1959 ev = QWhatsThisClickedEvent(help_url) 1960 handled = QCoreApplication.sendEvent(self, ev) 1961 1962 if not handled: 1963 message_information( 1964 self.tr("Sorry there is no documentation available for " 1965 "this widget."), 1966 parent=self) 1967 1968 def __toggleLinkEnabled(self, enabled): 1969 # type: (bool) -> None 1970 """ 1971 Link 'enabled' state was toggled in the context menu. 1972 """ 1973 if self.__contextMenuTarget: 1974 link = self.__contextMenuTarget 1975 command = commands.SetAttrCommand( 1976 link, "enabled", enabled, name=self.tr("Set enabled"), 1977 ) 1978 self.__undoStack.push(command) 1979 1980 def __linkRemove(self): 1981 # type: () -> None 1982 """ 1983 Remove link was requested from the context menu. 1984 """ 1985 if self.__contextMenuTarget: 1986 self.removeLink(self.__contextMenuTarget) 1987 1988 def __linkReset(self): 1989 # type: () -> None 1990 """ 1991 Link reset from the context menu was requested. 1992 """ 1993 if self.__contextMenuTarget: 1994 link = self.__contextMenuTarget 1995 action = interactions.EditNodeLinksAction( 1996 self, link.source_node, link.sink_node 1997 ) 1998 action.edit_links() 1999 2000 def __nodeInsert(self): 2001 # type: () -> None 2002 """ 2003 Node insert was requested from the context menu. 2004 """ 2005 if not self.__contextMenuTarget: 2006 return 2007 2008 original_link = self.__contextMenuTarget 2009 source_node = original_link.source_node 2010 sink_node = original_link.sink_node 2011 2012 def filterFunc(index): 2013 desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE) 2014 if isinstance(desc, WidgetDescription): 2015 return can_insert_node(desc, original_link) 2016 else: 2017 return False 2018 2019 x = (source_node.position[0] + sink_node.position[0]) / 2 2020 y = (source_node.position[1] + sink_node.position[1]) / 2 2021 2022 menu = self.quickMenu() 2023 menu.setFilterFunc(filterFunc) 2024 menu.setSortingFunc(None) 2025 2026 view = self.view() 2027 try: 2028 action = menu.exec_(view.mapToGlobal(view.mapFromScene(QPointF(x, y)))) 2029 finally: 2030 menu.setFilterFunc(None) 2031 2032 if action: 2033 item = action.property("item") 2034 desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE) 2035 else: 2036 return 2037 2038 if can_insert_node(desc, original_link): 2039 statistics = self.usageStatistics() 2040 statistics.begin_insert_action(False, original_link) 2041 new_node = self.newNodeHelper(desc, position=(x, y)) 2042 self.insertNode(new_node, original_link) 2043 else: 2044 log.info("Cannot insert node: links not possible.") 2045 2046 def __duplicateSelected(self): 2047 # type: () -> None 2048 """ 2049 Duplicate currently selected nodes. 2050 """ 2051 nodedups, linkdups = self.__copySelected() 2052 if not nodedups: 2053 return 2054 2055 pos = nodes_top_left(nodedups) 2056 self.__paste(nodedups, linkdups, pos + DuplicateOffset, 2057 commandname=self.tr("Duplicate")) 2058 2059 def __copyToClipboard(self): 2060 """ 2061 Copy currently selected nodes to system clipboard. 2062 """ 2063 cb = QApplication.clipboard() 2064 selected = self.__copySelected() 2065 nodes, links = selected 2066 if not nodes: 2067 return 2068 s = Scheme() 2069 for n in nodes: 2070 s.add_node(n) 2071 for e in links: 2072 s.add_link(e) 2073 buff = io.BytesIO() 2074 try: 2075 s.save_to(buff, pickle_fallback=True) 2076 except Exception: 2077 log.error("copyToClipboard:", exc_info=True) 2078 QApplication.beep() 2079 return 2080 mime = QMimeData() 2081 mime.setData(MimeTypeWorkflowFragment, buff.getvalue()) 2082 cb.setMimeData(mime) 2083 self.__pasteOrigin = nodes_top_left(nodes) + DuplicateOffset 2084 2085 def __updatePasteActionState(self): 2086 self.__pasteAction.setEnabled( 2087 clipboard_has_format(MimeTypeWorkflowFragment) 2088 ) 2089 2090 def __copySelected(self): 2091 """ 2092 Return a deep copy of currently selected nodes and links between them. 2093 """ 2094 scheme = self.scheme() 2095 if scheme is None: 2096 return [], [] 2097 2098 # ensure up to date node properties (settings) 2099 scheme.sync_node_properties() 2100 2101 # original nodes and links 2102 nodes = self.selectedNodes() 2103 links = [link for link in scheme.links 2104 if link.source_node in nodes and 2105 link.sink_node in nodes] 2106 2107 # deepcopied nodes and links 2108 nodedups = [copy_node(node) for node in nodes] 2109 node_to_dup = dict(zip(nodes, nodedups)) 2110 linkdups = [copy_link(link, source=node_to_dup[link.source_node], 2111 sink=node_to_dup[link.sink_node]) 2112 for link in links] 2113 2114 return nodedups, linkdups 2115 2116 def __pasteFromClipboard(self): 2117 """Paste a workflow part from system clipboard.""" 2118 buff = clipboard_data(MimeTypeWorkflowFragment) 2119 if buff is None: 2120 return 2121 sch = Scheme() 2122 try: 2123 sch.load_from(io.BytesIO(buff), registry=self.__registry, ) 2124 except Exception: 2125 log.error("pasteFromClipboard:", exc_info=True) 2126 QApplication.beep() 2127 return 2128 self.__paste(sch.nodes, sch.links, self.__pasteOrigin) 2129 self.__pasteOrigin = self.__pasteOrigin + DuplicateOffset 2130 2131 def __paste(self, nodedups, linkdups, pos: Optional[QPointF] = None, 2132 commandname=None): 2133 """ 2134 Paste nodes and links to canvas. Arguments are expected to be duplicated nodes/links. 2135 """ 2136 scheme = self.scheme() 2137 if scheme is None: 2138 return 2139 2140 # find unique names for new nodes 2141 allnames = {node.title for node in scheme.nodes + nodedups} 2142 for nodedup in nodedups: 2143 nodedup.title = uniquify( 2144 nodedup.title, allnames, pattern="{item} ({_})", start=1) 2145 2146 if pos is not None: 2147 # top left of nodedups brect 2148 origin = nodes_top_left(nodedups) 2149 delta = pos - origin 2150 # move nodedups to be relative to pos 2151 for nodedup in nodedups: 2152 nodedup.position = ( 2153 nodedup.position[0] + delta.x(), 2154 nodedup.position[1] + delta.y(), 2155 ) 2156 if commandname is None: 2157 commandname = self.tr("Paste") 2158 # create nodes, links 2159 command = UndoCommand(commandname) 2160 macrocommands = [] 2161 for nodedup in nodedups: 2162 macrocommands.append( 2163 commands.AddNodeCommand(scheme, nodedup, parent=command)) 2164 for linkdup in linkdups: 2165 macrocommands.append( 2166 commands.AddLinkCommand(scheme, linkdup, parent=command)) 2167 2168 statistics = self.usageStatistics() 2169 statistics.begin_action(UsageStatistics.Duplicate) 2170 self.__undoStack.push(command) 2171 scene = self.__scene 2172 2173 # deselect selected 2174 selected = self.scene().selectedItems() 2175 for item in selected: 2176 item.setSelected(False) 2177 2178 # select pasted 2179 for node in nodedups: 2180 item = scene.item_for_node(node) 2181 item.setSelected(True) 2182 2183 def __startControlPointEdit(self, item): 2184 # type: (items.annotationitem.Annotation) -> None 2185 """ 2186 Start a control point edit interaction for `item`. 2187 """ 2188 if isinstance(item, items.ArrowAnnotation): 2189 handler = interactions.ResizeArrowAnnotation(self) 2190 elif isinstance(item, items.TextAnnotation): 2191 handler = interactions.ResizeTextAnnotation(self) 2192 else: 2193 log.warning("Unknown annotation item type %r" % item) 2194 return 2195 2196 handler.editItem(item) 2197 self._setUserInteractionHandler(handler) 2198 2199 log.info("Control point editing started (%r)." % item) 2200 2201 def __endControlPointEdit(self): 2202 # type: () -> None 2203 """ 2204 End the current control point edit interaction. 2205 """ 2206 handler = self.__scene.user_interaction_handler 2207 if isinstance(handler, (interactions.ResizeArrowAnnotation, 2208 interactions.ResizeTextAnnotation)) and \ 2209 not handler.isFinished() and not handler.isCanceled(): 2210 handler.commit() 2211 handler.end() 2212 2213 log.info("Control point editing finished.") 2214 2215 def __updateFont(self): 2216 # type: () -> None 2217 """ 2218 Update the font for the "Text size' menu and the default font 2219 used in the `CanvasScene`. 2220 """ 2221 actions = self.__fontActionGroup.actions() 2222 font = self.font() 2223 for action in actions: 2224 size = action.font().pixelSize() 2225 action_font = QFont(font) 2226 action_font.setPixelSize(size) 2227 action.setFont(action_font) 2228 2229 if self.__scene: 2230 self.__scene.setFont(font) 2231 2232 def __signalManagerStateChanged(self, state): 2233 # type: (RuntimeState) -> None 2234 if state == RuntimeState.Running: 2235 role = QPalette.Base 2236 else: 2237 role = QPalette.Window 2238 self.__view.viewport().setBackgroundRole(role) 2239 2240 def __reset_window_group_menu(self): 2241 group = self.__windowGroupsActionGroup 2242 menu = self.__windowGroupsAction.menu() 2243 # remove old actions 2244 actions = group.actions() 2245 for a in actions: 2246 group.removeAction(a) 2247 menu.removeAction(a) 2248 a.deleteLater() 2249 2250 sep = menu.findChild(QAction, "groups-separator") 2251 2252 workflow = self.__scheme 2253 if workflow is None: 2254 return 2255 2256 presets = workflow.window_group_presets() 2257 2258 for g in presets: 2259 a = QAction(g.name, menu) 2260 a.setShortcut( 2261 QKeySequence("Meta+P, Ctrl+{}" 2262 .format(len(group.actions()) + 1)) 2263 ) 2264 a.setData(g) 2265 group.addAction(a) 2266 menu.insertAction(sep, a) 2267 2268 def __saveWindowGroup(self): 2269 # type: () -> None 2270 """Run a 'Save Window Group' dialog""" 2271 workflow = self.__scheme 2272 manager = self.__widgetManager 2273 if manager is None or workflow is None: 2274 return 2275 state = manager.save_window_state() 2276 presets = workflow.window_group_presets() 2277 items = [g.name for g in presets] 2278 default = [i for i, g in enumerate(presets) if g.default] 2279 dlg = SaveWindowGroup( 2280 self, windowTitle="Save Group as...") 2281 dlg.setWindowModality(Qt.ApplicationModal) 2282 dlg.setItems(items) 2283 if default: 2284 dlg.setDefaultIndex(default[0]) 2285 2286 def store_group(): 2287 text = dlg.selectedText() 2288 default = dlg.isDefaultChecked() 2289 try: 2290 idx = items.index(text) 2291 except ValueError: 2292 idx = -1 2293 newpresets = [copy.copy(g) for g in presets] # shallow copy 2294 newpreset = Scheme.WindowGroup(text, default, state) 2295 if idx == -1: 2296 # new group slot 2297 newpresets.append(newpreset) 2298 else: 2299 newpresets[idx] = newpreset 2300 2301 if newpreset.default: 2302 idx_ = idx if idx >= 0 else len(newpresets) - 1 2303 for g in newpresets[:idx_] + newpresets[idx_ + 1:]: 2304 g.default = False 2305 2306 if idx == -1: 2307 text = "Store Window Group" 2308 else: 2309 text = "Update Window Group" 2310 2311 self.__undoStack.push( 2312 commands.SetWindowGroupPresets(workflow, newpresets, text=text) 2313 ) 2314 dlg.accepted.connect(store_group) 2315 dlg.show() 2316 dlg.raise_() 2317 2318 def __activateWindowGroup(self, action): 2319 # type: (QAction) -> None 2320 data = action.data() # type: Scheme.WindowGroup 2321 wm = self.__widgetManager 2322 if wm is not None: 2323 wm.activate_window_group(data) 2324 2325 def __clearWindowGroups(self): 2326 # type: () -> None 2327 workflow = self.__scheme 2328 if workflow is None: 2329 return 2330 self.__undoStack.push( 2331 commands.SetWindowGroupPresets( 2332 workflow, [], text="Delete All Window Groups") 2333 ) 2334 2335 def __raiseToFont(self): 2336 # Raise current visible widgets to front 2337 wm = self.__widgetManager 2338 if wm is not None: 2339 wm.raise_widgets_to_front() 2340 2341 def activateDefaultWindowGroup(self): 2342 # type: () -> bool 2343 """ 2344 Activate the default window group if one exists. 2345 2346 Return `True` if a default group exists and was activated; `False` if 2347 not. 2348 """ 2349 for action in self.__windowGroupsActionGroup.actions(): 2350 g = action.data() 2351 if g.default: 2352 action.trigger() 2353 return True 2354 return False 2355 2356 def widgetManager(self): 2357 # type: () -> Optional[WidgetManager] 2358 """ 2359 Return the widget manager. 2360 """ 2361 return self.__widgetManager 2362 2363 2364class SaveWindowGroup(QDialog): 2365 """ 2366 A dialog for saving window groups. 2367 2368 The user can select an existing group to overwrite or enter a new group 2369 name. 2370 """ 2371 def __init__(self, *args, **kwargs): 2372 super().__init__(*args, **kwargs) 2373 layout = QVBoxLayout() 2374 form = QFormLayout( 2375 fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow) 2376 layout.addLayout(form) 2377 self._combobox = cb = QComboBox( 2378 editable=True, minimumContentsLength=16, 2379 sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLength, 2380 insertPolicy=QComboBox.NoInsert, 2381 ) 2382 cb.currentIndexChanged.connect(self.__currentIndexChanged) 2383 # default text if no items are present 2384 cb.setEditText(self.tr("Window Group 1")) 2385 cb.lineEdit().selectAll() 2386 form.addRow(self.tr("Save As:"), cb) 2387 self._checkbox = check = QCheckBox( 2388 self.tr("Use as default"), 2389 toolTip="Automatically use this preset when opening the workflow." 2390 ) 2391 form.setWidget(1, QFormLayout.FieldRole, check) 2392 bb = QDialogButtonBox( 2393 standardButtons=QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 2394 bb.accepted.connect(self.__accept_check) 2395 bb.rejected.connect(self.reject) 2396 layout.addWidget(bb) 2397 layout.setSizeConstraint(QVBoxLayout.SetFixedSize) 2398 self.setLayout(layout) 2399 self.setWhatsThis( 2400 "Save the current open widgets' window arrangement to the " 2401 "workflow view presets." 2402 ) 2403 cb.setFocus(Qt.NoFocusReason) 2404 2405 def __currentIndexChanged(self, idx): 2406 # type: (int) -> None 2407 state = self._combobox.itemData(idx, Qt.UserRole + 1) 2408 if not isinstance(state, bool): 2409 state = False 2410 self._checkbox.setChecked(state) 2411 2412 def __accept_check(self): 2413 # type: () -> None 2414 cb = self._combobox 2415 text = cb.currentText() 2416 if cb.findText(text) == -1: 2417 self.accept() 2418 return 2419 # Ask for overwrite confirmation 2420 mb = QMessageBox( 2421 self, windowTitle=self.tr("Confirm Overwrite"), 2422 icon=QMessageBox.Question, 2423 standardButtons=QMessageBox.Yes | QMessageBox.Cancel, 2424 text=self.tr("The window group '{}' already exists. Do you want " + 2425 "to replace it?").format(text), 2426 ) 2427 mb.setDefaultButton(QMessageBox.Yes) 2428 mb.setEscapeButton(QMessageBox.Cancel) 2429 mb.setWindowModality(Qt.WindowModal) 2430 button = mb.button(QMessageBox.Yes) 2431 button.setText(self.tr("Replace")) 2432 2433 def on_finished(status): # type: (int) -> None 2434 if status == QMessageBox.Yes: 2435 self.accept() 2436 mb.finished.connect(on_finished) 2437 mb.show() 2438 2439 def setItems(self, items): 2440 # type: (List[str]) -> None 2441 """Set a list of existing items/names to present to the user""" 2442 self._combobox.clear() 2443 self._combobox.addItems(items) 2444 if items: 2445 self._combobox.setCurrentIndex(len(items) - 1) 2446 2447 def setDefaultIndex(self, idx): 2448 # type: (int) -> None 2449 self._combobox.setItemData(idx, True, Qt.UserRole + 1) 2450 self._checkbox.setChecked(self._combobox.currentIndex() == idx) 2451 2452 def selectedText(self): 2453 # type: () -> str 2454 """Return the current entered text.""" 2455 return self._combobox.currentText() 2456 2457 def isDefaultChecked(self): 2458 # type: () -> bool 2459 """Return the state of the 'Use as default' check box.""" 2460 return self._checkbox.isChecked() 2461 2462 2463def geometry_from_annotation_item(item): 2464 if isinstance(item, items.ArrowAnnotation): 2465 line = item.line() 2466 p1 = item.mapToScene(line.p1()) 2467 p2 = item.mapToScene(line.p2()) 2468 return ((p1.x(), p1.y()), (p2.x(), p2.y())) 2469 elif isinstance(item, items.TextAnnotation): 2470 geom = item.geometry() 2471 return (geom.x(), geom.y(), geom.width(), geom.height()) 2472 2473 2474def mouse_drag_distance(event, button=Qt.LeftButton): 2475 # type: (QGraphicsSceneMouseEvent, Qt.MouseButton) -> float 2476 """ 2477 Return the (manhattan) distance between the mouse position 2478 when the `button` was pressed and the current mouse position. 2479 """ 2480 diff = (event.buttonDownScreenPos(button) - event.screenPos()) 2481 return diff.manhattanLength() 2482 2483 2484def set_enabled_all(objects, enable): 2485 # type: (Iterable[Any], bool) -> None 2486 """ 2487 Set `enabled` properties on all objects (objects with `setEnabled` method). 2488 """ 2489 for obj in objects: 2490 obj.setEnabled(enable) 2491 2492 2493# All control character categories. 2494_control = set(["Cc", "Cf", "Cs", "Co", "Cn"]) 2495 2496 2497def is_printable(unichar): 2498 # type: (str) -> bool 2499 """ 2500 Return True if the unicode character `unichar` is a printable character. 2501 """ 2502 return unicodedata.category(unichar) not in _control 2503 2504 2505def node_properties(scheme): 2506 # type: (Scheme) -> Dict[str, Dict[str, Any]] 2507 scheme.sync_node_properties() 2508 return { 2509 node: dict(node.properties) for node in scheme.nodes 2510 } 2511 2512 2513def can_insert_node(new_node_desc, original_link): 2514 # type: (WidgetDescription, SchemeLink) -> bool 2515 return any(any(scheme.compatible_channels(output, input) 2516 for input in new_node_desc.inputs) 2517 for output in original_link.source_node.output_channels()) and \ 2518 any(any(scheme.compatible_channels(output, input) 2519 for output in new_node_desc.outputs) 2520 for input in original_link.sink_node.input_channels()) 2521 2522 2523def uniquify(item, names, pattern="{item}-{_}", start=0): 2524 # type: (str, Container[str], str, int) -> str 2525 candidates = (pattern.format(item=item, _=i) 2526 for i in itertools.count(start)) 2527 candidates = itertools.dropwhile(lambda item: item in names, candidates) 2528 return next(candidates) 2529 2530 2531def copy_node(node): 2532 # type: (SchemeNode) -> SchemeNode 2533 return SchemeNode( 2534 node.description, node.title, position=node.position, 2535 properties=copy.deepcopy(node.properties) 2536 ) 2537 2538 2539def copy_link(link, source=None, sink=None): 2540 # type: (SchemeLink, Optional[SchemeNode], Optional[SchemeNode]) -> SchemeLink 2541 source = link.source_node if source is None else source 2542 sink = link.sink_node if sink is None else sink 2543 return SchemeLink( 2544 source, link.source_channel, 2545 sink, link.sink_channel, 2546 enabled=link.enabled, 2547 properties=copy.deepcopy(link.properties)) 2548 2549 2550def nodes_top_left(nodes): 2551 # type: (List[SchemeNode]) -> QPointF 2552 """Return the top left point of bbox containing all the node positions.""" 2553 return QPointF( 2554 min((n.position[0] for n in nodes), default=0), 2555 min((n.position[1] for n in nodes), default=0) 2556 ) 2557 2558@contextmanager 2559def disable_undo_stack_actions( 2560 undo: QAction, redo: QAction, stack: QUndoStack 2561) -> Generator[None, None, None]: 2562 """ 2563 Disable the undo/redo actions of an undo stack. 2564 2565 On exit restore the enabled state to match the `stack.canUndo()` 2566 and `stack.canRedo()`. 2567 2568 Parameters 2569 ---------- 2570 undo: QAction 2571 redo: QAction 2572 stack: QUndoStack 2573 2574 Returns 2575 ------- 2576 context: ContextManager 2577 """ 2578 undo.setEnabled(False) 2579 redo.setEnabled(False) 2580 try: 2581 yield 2582 finally: 2583 undo.setEnabled(stack.canUndo()) 2584 redo.setEnabled(stack.canRedo()) 2585