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