"""
====================
Scheme Editor Widget
====================


"""

import sys
import logging
import itertools
import unicodedata
import copy

from operator import attrgetter
from urllib.parse import urlencode
from contextlib import ExitStack

from typing import List, Tuple, Optional, Container, Dict, Any, Iterable

from AnyQt.QtWidgets import (
    QWidget, QVBoxLayout, QInputDialog, QMenu, QAction, QActionGroup,
    QUndoStack, QUndoCommand, QGraphicsItem, QGraphicsTextItem,
    QFormLayout, QComboBox, QDialog, QDialogButtonBox, QMessageBox, QCheckBox,
    QGraphicsSceneDragDropEvent, QGraphicsSceneMouseEvent,
    QGraphicsSceneContextMenuEvent, QGraphicsView, QGraphicsScene
)
from AnyQt.QtGui import (
    QKeySequence, QCursor, QFont, QPainter, QPixmap, QColor, QIcon,
    QWhatsThisClickedEvent, QKeyEvent, QPalette
)
from AnyQt.QtCore import (
    Qt, QObject, QEvent, QSignalMapper, QCoreApplication, QPoint, QPointF
)
from AnyQt.QtCore import pyqtProperty as Property, pyqtSignal as Signal

from ..registry import WidgetDescription, WidgetRegistry
from .suggestions import Suggestions
from .usagestatistics import UsageStatistics
from ..registry.qt import whats_this_helper, QtWidgetRegistry
from ..gui.quickhelp import QuickHelpTipEvent
from ..gui.utils import message_information, disabled
from ..scheme import (
    scheme, signalmanager, Scheme, SchemeNode, SchemeLink,
    BaseSchemeAnnotation, SchemeTextAnnotation, WorkflowEvent
)
from ..scheme.widgetmanager import WidgetManager
from ..canvas.scene import CanvasScene
from ..canvas.view import CanvasView
from ..canvas import items
from ..canvas.items.annotationitem import Annotation as AnnotationItem
from . import interactions
from . import commands
from . import quickmenu

Pos = Tuple[float, float]
RuntimeState = signalmanager.SignalManager.State

log = logging.getLogger(__name__)


class NoWorkflowError(RuntimeError):
    def __init__(self, message: str = "No workflow model is set", **kwargs):
        super().__init__(message, *kwargs)


class SchemeEditWidget(QWidget):
    """
    A widget for editing a :class:`~.scheme.Scheme` instance.

    """
    #: Undo command has become available/unavailable.
    undoAvailable = Signal(bool)

    #: Redo command has become available/unavailable.
    redoAvailable = Signal(bool)

    #: Document modified state has changed.
    modificationChanged = Signal(bool)

    #: Undo command was added to the undo stack.
    undoCommandAdded = Signal()

    #: Item selection has changed.
    selectionChanged = Signal()

    #: Document title has changed.
    titleChanged = Signal(str)

    #: Document path has changed.
    pathChanged = Signal(str)

    # Quick Menu triggers
    (NoTriggers,
     RightClicked,
     DoubleClicked,
     SpaceKey,
     AnyKey) = [0, 1, 2, 4, 8]

    def __init__(self, parent=None, ):
        super().__init__(parent)

        self.__modified = False
        self.__registry = None       # type: Optional[WidgetRegistry]
        self.__scheme = None         # type: Optional[Scheme]

        self.__widgetManager = None  # type: Optional[WidgetManager]
        self.__path = ""

        self.__quickMenuTriggers = SchemeEditWidget.SpaceKey | \
                                   SchemeEditWidget.DoubleClicked
        self.__emptyClickButtons = 0
        self.__channelNamesVisible = True
        self.__nodeAnimationEnabled = True
        self.__possibleSelectionHandler = None
        self.__possibleMouseItemsMove = False
        self.__itemsMoving = {}
        self.__contextMenuTarget = None  # type: Optional[SchemeLink]
        self.__dropTarget = None  # type: Optional[items.LinkItem]
        self.__quickMenu = None   # type: Optional[quickmenu.QuickMenu]
        self.__quickTip = ""

        self.__undoStack = QUndoStack(self)
        self.__undoStack.cleanChanged[bool].connect(self.__onCleanChanged)

        # scheme node properties when set to a clean state
        self.__cleanProperties = []

        self.__editFinishedMapper = QSignalMapper(self)
        self.__editFinishedMapper.mapped[QObject].connect(
            self.__onEditingFinished
        )

        self.__annotationGeomChanged = QSignalMapper(self)

        self.__setupActions()
        self.__setupUi()

        # Edit menu for a main window menu bar.
        self.__editMenu = QMenu(self.tr("&Edit"), self)
        self.__editMenu.addAction(self.__undoAction)
        self.__editMenu.addAction(self.__redoAction)
        self.__editMenu.addSeparator()
        self.__editMenu.addAction(self.__removeSelectedAction)
        self.__editMenu.addAction(self.__duplicateSelectedAction)
        self.__editMenu.addAction(self.__selectAllAction)

        # Widget context menu
        self.__widgetMenu = QMenu(self.tr("Widget"), self)
        self.__widgetMenu.addAction(self.__openSelectedAction)
        self.__widgetMenu.addSeparator()
        self.__widgetMenu.addAction(self.__renameAction)
        self.__widgetMenu.addAction(self.__removeSelectedAction)
        self.__widgetMenu.addAction(self.__duplicateSelectedAction)
        self.__widgetMenu.addSeparator()
        self.__widgetMenu.addAction(self.__helpAction)

        # Widget menu for a main window menu bar.
        self.__menuBarWidgetMenu = QMenu(self.tr("&Widget"), self)
        self.__menuBarWidgetMenu.addAction(self.__openSelectedAction)
        self.__menuBarWidgetMenu.addSeparator()
        self.__menuBarWidgetMenu.addAction(self.__renameAction)
        self.__menuBarWidgetMenu.addAction(self.__removeSelectedAction)
        self.__menuBarWidgetMenu.addSeparator()
        self.__menuBarWidgetMenu.addAction(self.__helpAction)

        self.__linkMenu = QMenu(self.tr("Link"), self)
        self.__linkMenu.addAction(self.__linkEnableAction)
        self.__linkMenu.addSeparator()
        self.__linkMenu.addAction(self.__nodeInsertAction)
        self.__linkMenu.addSeparator()
        self.__linkMenu.addAction(self.__linkRemoveAction)
        self.__linkMenu.addAction(self.__linkResetAction)

        self.__suggestions = Suggestions()
        self.__statistics = UsageStatistics()

    def __setupActions(self):
        self.__cleanUpAction = QAction(
            self.tr("Clean Up"), self,
            objectName="cleanup-action",
            shortcut=QKeySequence("Shift+A"),
            toolTip=self.tr("Align widgets to a grid (Shift+A)"),
            triggered=self.alignToGrid,
        )

        self.__newTextAnnotationAction = QAction(
            self.tr("Text"), self,
            objectName="new-text-action",
            toolTip=self.tr("Add a text annotation to the workflow."),
            checkable=True,
            toggled=self.__toggleNewTextAnnotation,
        )

        # Create a font size menu for the new annotation action.
        self.__fontMenu = QMenu("Font Size", self)
        self.__fontActionGroup = group = QActionGroup(
            self, exclusive=True, triggered=self.__onFontSizeTriggered
        )

        def font(size):
            f = QFont(self.font())
            f.setPixelSize(size)
            return f

        for size in [12, 14, 16, 18, 20, 22, 24]:
            action = QAction(
                "%ipx" % size, group, checkable=True, font=font(size)
            )
            self.__fontMenu.addAction(action)

        group.actions()[2].setChecked(True)

        self.__newTextAnnotationAction.setMenu(self.__fontMenu)

        self.__newArrowAnnotationAction = QAction(
            self.tr("Arrow"), self,
            objectName="new-arrow-action",
            toolTip=self.tr("Add a arrow annotation to the workflow."),
            checkable=True,
            toggled=self.__toggleNewArrowAnnotation,
        )

        # Create a color menu for the arrow annotation action
        self.__arrowColorMenu = QMenu("Arrow Color",)
        self.__arrowColorActionGroup = group = QActionGroup(
            self, exclusive=True, triggered=self.__onArrowColorTriggered
        )

        def color_icon(color):
            icon = QIcon()
            for size in [16, 24, 32]:
                pixmap = QPixmap(size, size)
                pixmap.fill(QColor(0, 0, 0, 0))
                p = QPainter(pixmap)
                p.setRenderHint(QPainter.Antialiasing)
                p.setBrush(color)
                p.setPen(Qt.NoPen)
                p.drawEllipse(1, 1, size - 2, size - 2)
                p.end()
                icon.addPixmap(pixmap)
            return icon

        for color in ["#000", "#C1272D", "#662D91", "#1F9CDF", "#39B54A"]:
            icon = color_icon(QColor(color))
            action = QAction(group, icon=icon, checkable=True,
                             iconVisibleInMenu=True)
            action.setData(color)
            self.__arrowColorMenu.addAction(action)

        group.actions()[1].setChecked(True)

        self.__newArrowAnnotationAction.setMenu(self.__arrowColorMenu)

        self.__undoAction = self.__undoStack.createUndoAction(self)
        self.__undoAction.setShortcut(QKeySequence.Undo)
        self.__undoAction.setObjectName("undo-action")

        self.__redoAction = self.__undoStack.createRedoAction(self)
        self.__redoAction.setShortcut(QKeySequence.Redo)
        self.__redoAction.setObjectName("redo-action")

        self.__selectAllAction = QAction(
            self.tr("Select all"), self,
            objectName="select-all-action",
            toolTip=self.tr("Select all items."),
            triggered=self.selectAll,
            shortcut=QKeySequence.SelectAll
        )
        self.__openSelectedAction = QAction(
            self.tr("Open"), self,
            objectName="open-action",
            toolTip=self.tr("Open selected widget"),
            triggered=self.openSelected,
            enabled=False
        )
        self.__removeSelectedAction = QAction(
            self.tr("Remove"), self,
            objectName="remove-selected",
            toolTip=self.tr("Remove selected items"),
            triggered=self.removeSelected,
            enabled=False
        )

        shortcuts = [Qt.Key_Backspace,
                     Qt.Key_Delete,
                     Qt.ControlModifier + Qt.Key_Backspace]

        self.__removeSelectedAction.setShortcuts(shortcuts)

        self.__renameAction = QAction(
            self.tr("Rename"), self,
            objectName="rename-action",
            toolTip=self.tr("Rename selected widget"),
            triggered=self.__onRenameAction,
            shortcut=QKeySequence(Qt.Key_F2),
            enabled=False
        )

        self.__helpAction = QAction(
            self.tr("Help"), self,
            objectName="help-action",
            toolTip=self.tr("Show widget help"),
            triggered=self.__onHelpAction,
            shortcut=QKeySequence("F1"),
            enabled=False,
        )
        self.__linkEnableAction = QAction(
            self.tr("Enabled"), self, objectName="link-enable-action",
            triggered=self.__toggleLinkEnabled, checkable=True,
        )

        self.__linkRemoveAction = QAction(
            self.tr("Remove"), self,
            objectName="link-remove-action",
            triggered=self.__linkRemove,
            toolTip=self.tr("Remove link."),
        )

        self.__nodeInsertAction = QAction(
            self.tr("Insert Widget"), self,
            objectName="node-insert-action",
            triggered=self.__nodeInsert,
            toolTip=self.tr("Insert widget."),
        )

        self.__linkResetAction = QAction(
            self.tr("Reset Signals"), self,
            objectName="link-reset-action",
            triggered=self.__linkReset,
        )
        self.__duplicateSelectedAction = QAction(
            self.tr("Duplicate Selected"), self,
            objectName="duplicate-action",
            enabled=False,
            shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_D),
            triggered=self.__duplicateSelected,
        )

        self.addActions([
            self.__newTextAnnotationAction,
            self.__newArrowAnnotationAction,
            self.__linkEnableAction,
            self.__linkRemoveAction,
            self.__nodeInsertAction,
            self.__linkResetAction,
            self.__duplicateSelectedAction
        ])

        # Actions which should be disabled while a multistep
        # interaction is in progress.
        self.__disruptiveActions = [
            self.__undoAction,
            self.__redoAction,
            self.__removeSelectedAction,
            self.__selectAllAction,
            self.__duplicateSelectedAction
        ]

        #: Top 'Window Groups' action
        self.__windowGroupsAction = QAction(
            self.tr("Window Groups"), self, objectName="window-groups-action",
            toolTip="Manage preset widget groups"
        )
        #: Action group containing action for every window group
        self.__windowGroupsActionGroup = QActionGroup(
            self.__windowGroupsAction, objectName="window-groups-action-group",
        )
        self.__windowGroupsActionGroup.triggered.connect(
            self.__activateWindowGroup
        )
        self.__saveWindowGroupAction = QAction(
            self.tr("Save Window Group..."), self,
            toolTip="Create and save a new window group."
        )
        self.__saveWindowGroupAction.triggered.connect(self.__saveWindowGroup)
        self.__clearWindowGroupsAction = QAction(
            self.tr("Delete All Groups"), self,
            toolTip="Delete all saved widget presets"
        )
        self.__clearWindowGroupsAction.triggered.connect(
            self.__clearWindowGroups
        )

        groups_menu = QMenu(self)
        sep = groups_menu.addSeparator()
        sep.setObjectName("groups-separator")
        groups_menu.addAction(self.__saveWindowGroupAction)
        groups_menu.addSeparator()
        groups_menu.addAction(self.__clearWindowGroupsAction)
        self.__windowGroupsAction.setMenu(groups_menu)

        # the counterpart to Control + Key_Up to raise the containing workflow
        # view (maybe move that shortcut here)
        self.__raiseWidgetsAction = QAction(
            self.tr("Bring Widgets to Front"), self,
            objectName="bring-widgets-to-front-action",
            shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_Down),
            shortcutContext=Qt.WindowShortcut,
        )
        self.__raiseWidgetsAction.triggered.connect(self.__raiseToFont)
        self.addAction(self.__raiseWidgetsAction)

    def __setupUi(self):
        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        scene = CanvasScene(self)
        scene.setItemIndexMethod(CanvasScene.NoIndex)
        self.__setupScene(scene)

        view = CanvasView(scene)
        view.setFrameStyle(CanvasView.NoFrame)
        view.setRenderHint(QPainter.Antialiasing)

        self.__view = view
        self.__scene = scene

        layout.addWidget(view)
        self.setLayout(layout)

    def __setupScene(self, scene):
        # type: (CanvasScene) -> None
        """
        Set up a :class:`CanvasScene` instance for use by the editor.

        .. note:: If an existing scene is in use it must be teared down using
            __teardownScene
        """
        scene.set_channel_names_visible(self.__channelNamesVisible)
        scene.set_node_animation_enabled(
            self.__nodeAnimationEnabled
        )

        scene.setFont(self.font())
        scene.setPalette(self.palette())
        scene.installEventFilter(self)

        if self.__registry is not None:
            scene.set_registry(self.__registry)
        scene.focusItemChanged.connect(self.__onFocusItemChanged)
        scene.selectionChanged.connect(self.__onSelectionChanged)
        scene.node_item_activated.connect(self.__onNodeActivate)
        scene.annotation_added.connect(self.__onAnnotationAdded)
        scene.annotation_removed.connect(self.__onAnnotationRemoved)
        self.__annotationGeomChanged = QSignalMapper(self)

    def __teardownScene(self, scene):
        # type: (CanvasScene) -> None
        """
        Tear down an instance of :class:`CanvasScene` that was used by the
        editor.
        """
        # Clear the current item selection in the scene so edit action
        # states are updated accordingly.
        scene.clearSelection()

        # Clear focus from any item.
        scene.setFocusItem(None)

        # Clear the annotation mapper
        self.__annotationGeomChanged.deleteLater()
        self.__annotationGeomChanged = None
        scene.focusItemChanged.disconnect(self.__onFocusItemChanged)
        scene.selectionChanged.disconnect(self.__onSelectionChanged)
        scene.removeEventFilter(self)

        # Clear all items from the scene
        scene.blockSignals(True)
        scene.clear_scene()

    def toolbarActions(self):
        # type: () -> List[QAction]
        """
        Return a list of actions that can be inserted into a toolbar.
        At the moment these are:

            - 'Zoom in' action
            - 'Zoom out' action
            - 'Zoom Reset' action
            - 'Clean up' action (align to grid)
            - 'New text annotation' action (with a size menu)
            - 'New arrow annotation' action (with a color menu)

        """
        view = self.__view
        zoomin = view.findChild(QAction, "action-zoom-in")
        zoomout = view.findChild(QAction, "action-zoom-out")
        zoomreset = view.findChild(QAction, "action-zoom-reset")
        assert zoomin and zoomout and zoomreset
        return [zoomin,
                zoomout,
                zoomreset,
                self.__cleanUpAction,
                self.__newTextAnnotationAction,
                self.__newArrowAnnotationAction]

    def menuBarActions(self):
        # type: () -> List[QAction]
        """
        Return a list of actions that can be inserted into a `QMenuBar`.

        """
        return [self.__editMenu.menuAction(),
                self.__menuBarWidgetMenu.menuAction()]

    def isModified(self):
        # type: () -> bool
        """
        Is the document is a modified state.
        """
        return self.__modified or not self.__undoStack.isClean()

    def setModified(self, modified):
        # type: (bool) -> None
        """
        Set the document modified state.
        """
        if self.__modified != modified:
            self.__modified = modified

        if not modified:
            if self.__scheme:
                self.__cleanProperties = node_properties(self.__scheme)
            else:
                self.__cleanProperties = []
            self.__undoStack.setClean()
        else:
            self.__cleanProperties = []

    modified = Property(bool, fget=isModified, fset=setModified)

    def isModifiedStrict(self):
        """
        Is the document modified.

        Run a strict check against all node properties as they were
        at the time when the last call to `setModified(True)` was made.

        """
        propertiesChanged = self.__cleanProperties != \
                            node_properties(self.__scheme)

        log.debug("Modified strict check (modified flag: %s, "
                  "undo stack clean: %s, properties: %s)",
                  self.__modified,
                  self.__undoStack.isClean(),
                  propertiesChanged)

        return self.isModified() or propertiesChanged

    def setQuickMenuTriggers(self, triggers):
        # type: (int) -> None
        """
        Set quick menu trigger flags.

        Flags can be a bitwise `or` of:

            - `SchemeEditWidget.NoTrigeres`
            - `SchemeEditWidget.RightClicked`
            - `SchemeEditWidget.DoubleClicked`
            - `SchemeEditWidget.SpaceKey`
            - `SchemeEditWidget.AnyKey`

        """
        if self.__quickMenuTriggers != triggers:
            self.__quickMenuTriggers = triggers

    def quickMenuTriggers(self):
        # type: () -> int
        """
        Return quick menu trigger flags.
        """
        return self.__quickMenuTriggers

    def setChannelNamesVisible(self, visible):
        # type: (bool) -> None
        """
        Set channel names visibility state. When enabled the links
        in the view will have a source/sink channel names displayed over
        them.
        """
        if self.__channelNamesVisible != visible:
            self.__channelNamesVisible = visible
            self.__scene.set_channel_names_visible(visible)

    def channelNamesVisible(self):
        # type: () -> bool
        """
        Return the channel name visibility state.
        """
        return self.__channelNamesVisible

    def setNodeAnimationEnabled(self, enabled):
        # type: (bool) -> None
        """
        Set the node item animation enabled state.
        """
        if self.__nodeAnimationEnabled != enabled:
            self.__nodeAnimationEnabled = enabled
            self.__scene.set_node_animation_enabled(enabled)

    def nodeAnimationEnabled(self):
        # type () -> bool
        """
        Return the node item animation enabled state.
        """
        return self.__nodeAnimationEnabled

    def undoStack(self):
        # type: () -> QUndoStack
        """
        Return the undo stack.
        """
        return self.__undoStack

    def setPath(self, path):
        # type: (str) -> None
        """
        Set the path associated with the current scheme.

        .. note:: Calling `setScheme` will invalidate the path (i.e. set it
                  to an empty string)

        """
        if self.__path != path:
            self.__path = path
            self.pathChanged.emit(self.__path)

    def path(self):
        # type: () -> str
        """
        Return the path associated with the scheme
        """
        return self.__path

    def setScheme(self, scheme):
        # type: (Scheme) -> None
        """
        Set the :class:`~.scheme.Scheme` instance to display/edit.
        """
        if self.__scheme is not scheme:
            if self.__scheme:
                self.__scheme.title_changed.disconnect(self.titleChanged)
                self.__scheme.removeEventFilter(self)
                sm = self.__scheme.findChild(signalmanager.SignalManager)
                if sm:
                    sm.stateChanged.disconnect(
                        self.__signalManagerStateChanged)
                self.__widgetManager = None

            self.__scheme = scheme
            self.__suggestions.set_scheme(self)

            self.setPath("")

            if self.__scheme:
                self.__scheme.title_changed.connect(self.titleChanged)
                self.titleChanged.emit(scheme.title)
                self.__cleanProperties = node_properties(scheme)
                sm = scheme.findChild(signalmanager.SignalManager)
                if sm:
                    sm.stateChanged.connect(self.__signalManagerStateChanged)
                self.__widgetManager = getattr(scheme, "widget_manager", None)
            else:
                self.__cleanProperties = []

            self.__teardownScene(self.__scene)
            self.__scene.deleteLater()

            self.__undoStack.clear()

            self.__scene = CanvasScene(self)
            self.__scene.setItemIndexMethod(CanvasScene.NoIndex)
            self.__setupScene(self.__scene)

            self.__scene.set_scheme(scheme)
            self.__view.setScene(self.__scene)

            if self.__scheme:
                self.__scheme.installEventFilter(self)
                nodes = self.__scheme.nodes
                if nodes:
                    self.ensureVisible(nodes[0])

        group = self.__windowGroupsActionGroup
        menu = self.__windowGroupsAction.menu()
        actions = group.actions()
        for a in actions:
            group.removeAction(a)
            menu.removeAction(a)
            a.deleteLater()

        if scheme:
            presets = scheme.window_group_presets()
            sep = menu.findChild(QAction, "groups-separator")
            for g in presets:
                a = QAction(g.name, menu)
                a.setShortcut(
                    QKeySequence("Meta+P, Ctrl+{}"
                                 .format(len(group.actions()) + 1))
                )
                a.setData(g)
                group.addAction(a)
                menu.insertAction(sep, a)

    def ensureVisible(self, node):
        # type: (SchemeNode) -> None
        """
        Scroll the contents of the viewport so that `node` is visible.

        Parameters
        ----------
        node: SchemeNode
        """
        if self.__scheme is None:
            return
        item = self.__scene.item_for_node(node)
        self.__view.ensureVisible(item)

    def scheme(self):
        # type: () -> Optional[Scheme]
        """
        Return the :class:`~.scheme.Scheme` edited by the widget.
        """
        return self.__scheme

    def scene(self):
        # type: () -> QGraphicsScene
        """
        Return the :class:`QGraphicsScene` instance used to display the
        current scheme.
        """
        return self.__scene

    def view(self):
        # type: () -> QGraphicsView
        """
        Return the :class:`QGraphicsView` instance used to display the
        current scene.
        """
        return self.__view

    def suggestions(self):
        """
        Return the widget suggestion prediction class.
        """
        return self.__suggestions

    def usageStatistics(self):
        """
        Return the usage statistics logging class.
        """
        return self.__statistics

    def setRegistry(self, registry):
        # Is this method necessary?
        # It should be removed when the scene (items) is fixed
        # so all information regarding the visual appearance is
        # included in the node/widget description.
        self.__registry = registry
        if self.__scene:
            self.__scene.set_registry(registry)
            self.__quickMenu = None

    def quickMenu(self):
        # type: () -> quickmenu.QuickMenu
        """
        Return a :class:`~.quickmenu.QuickMenu` popup menu instance for
        new node creation.
        """
        if self.__quickMenu is None:
            menu = quickmenu.QuickMenu(self)
            if self.__registry is not None:
                menu.setModel(self.__registry.model())
            self.__quickMenu = menu
        return self.__quickMenu

    def setTitle(self, title):
        # type: (str) -> None
        """
        Set the scheme title.
        """
        self.__undoStack.push(
            commands.SetAttrCommand(self.__scheme, "title", title)
        )

    def setDescription(self, description):
        # type: (str) -> None
        """
        Set the scheme description string.
        """
        self.__undoStack.push(
            commands.SetAttrCommand(self.__scheme, "description", description)
        )

    def addNode(self, node):
        # type: (SchemeNode) -> None
        """
        Add a new node (:class:`.SchemeNode`) to the document.
        """
        if self.__scheme is None:
            raise NoWorkflowError()
        self.__statistics.log_node_added(node.description.name)
        command = commands.AddNodeCommand(self.__scheme, node)
        self.__undoStack.push(command)

    def createNewNode(self, description, title=None, position=None):
        # type: (WidgetDescription, Optional[str], Optional[Pos]) -> SchemeNode
        """
        Create a new :class:`.SchemeNode` and add it to the document.
        The new node is constructed using :func:`~SchemeEdit.newNodeHelper`
        method
        """
        node = self.newNodeHelper(description, title, position)
        self.addNode(node)

        return node

    def newNodeHelper(self, description, title=None, position=None):
        # type: (WidgetDescription, Optional[str], Optional[Pos]) -> SchemeNode
        """
        Return a new initialized :class:`.SchemeNode`. If `title`
        and `position` are not supplied they are initialized to sensible
        defaults.
        """
        if title is None:
            title = self.enumerateTitle(description.name)

        if position is None:
            position = self.nextPosition()

        return SchemeNode(description, title=title, position=position)

    def enumerateTitle(self, title):
        # type: (str) -> str
        """
        Enumerate a `title` string (i.e. add a number in parentheses) so
        it is not equal to any node title in the current scheme.
        """
        if self.__scheme is None:
            return title
        curr_titles = set([node.title for node in self.__scheme.nodes])
        template = title + " ({0})"

        enumerated = (template.format(i) for i in itertools.count(1))
        candidates = itertools.chain([title], enumerated)

        seq = itertools.dropwhile(curr_titles.__contains__, candidates)
        return next(seq)

    def nextPosition(self):
        # type: () -> Tuple[float, float]
        """
        Return the next default node position as a (x, y) tuple. This is
        a position left of the last added node.
        """
        if self.__scheme is not None:
            nodes = self.__scheme.nodes
        else:
            nodes = []
        if nodes:
            x, y = nodes[-1].position  # type: ignore
            position = (x + 150, y)
        else:
            position = (150, 150)
        return position

    def removeNode(self, node):
        # type: (SchemeNode) -> None
        """
        Remove a `node` (:class:`.SchemeNode`) from the scheme
        """
        if self.__scheme is None:
            raise NoWorkflowError()
        command = commands.RemoveNodeCommand(self.__scheme, node)
        self.__undoStack.push(command)

    def renameNode(self, node, title):
        # type: (SchemeNode, str) -> None
        """
        Rename a `node` (:class:`.SchemeNode`) to `title`.
        """
        if self.__scheme is None:
            raise NoWorkflowError()
        self.__undoStack.push(
            commands.RenameNodeCommand(self.__scheme, node, node.title, title)
        )

    def addLink(self, link):
        # type: (SchemeLink) -> None
        """
        Add a `link` (:class:`.SchemeLink`) to the scheme.
        """
        if self.__scheme is None:
            raise NoWorkflowError()
        command = commands.AddLinkCommand(self.__scheme, link)
        self.__undoStack.push(command)

    def removeLink(self, link):
        # type: (SchemeLink) -> None
        """
        Remove a link (:class:`.SchemeLink`) from the scheme.
        """
        if self.__scheme is None:
            raise NoWorkflowError()
        command = commands.RemoveLinkCommand(self.__scheme, link)
        self.__undoStack.push(command)

    def insertNode(self, new_node, old_link):
        # type: (SchemeNode, SchemeLink) -> None
        """
        Insert a node in-between two linked nodes.
        """
        if self.__scheme is None:
            raise NoWorkflowError()
        source_node = old_link.source_node
        sink_node = old_link.sink_node

        possible_links = (self.__scheme.propose_links(source_node, new_node),
                          self.__scheme.propose_links(new_node, sink_node))

        first_link_sink_channel = [l[1] for l in possible_links[0]
                                   if l[0] == old_link.source_channel][0]
        second_link_source_channel = [l[0] for l in possible_links[1]
                                      if l[1] == old_link.sink_channel][0]

        new_links = (
            SchemeLink(source_node, old_link.source_channel,
                       new_node, first_link_sink_channel),
            SchemeLink(new_node, second_link_source_channel,
                       sink_node, old_link.sink_channel))

        command = commands.InsertNodeCommand(self.__scheme, new_node, old_link, new_links)
        self.__undoStack.push(command)

    def onNewLink(self, func):
        """
        Runs function when new link is added to current scheme.
        """
        self.__scheme.link_added.connect(func)

    def addAnnotation(self, annotation):
        # type: (BaseSchemeAnnotation) -> None
        """
        Add `annotation` (:class:`.BaseSchemeAnnotation`) to the scheme
        """
        if self.__scheme is None:
            raise NoWorkflowError()
        command = commands.AddAnnotationCommand(self.__scheme, annotation)
        self.__undoStack.push(command)

    def removeAnnotation(self, annotation):
        # type: (BaseSchemeAnnotation) -> None
        """
        Remove `annotation` (:class:`.BaseSchemeAnnotation`) from the scheme.
        """
        if self.__scheme is None:
            raise NoWorkflowError()
        command = commands.RemoveAnnotationCommand(self.__scheme, annotation)
        self.__undoStack.push(command)

    def removeSelected(self):
        # type: () -> None
        """
        Remove all selected items in the scheme.
        """
        selected = self.scene().selectedItems()
        if not selected:
            return
        scene = self.scene()
        self.__undoStack.beginMacro(self.tr("Remove"))
        for item in selected:
            assert self.__scheme is not None
            if isinstance(item, items.NodeItem):
                node = self.scene().node_for_item(item)
                self.__undoStack.push(
                    commands.RemoveNodeCommand(self.__scheme, node)
                )
            elif isinstance(item, items.annotationitem.Annotation):
                if item.hasFocus() or item.isAncestorOf(scene.focusItem()):
                    # Clear input focus from the item to be removed.
                    scene.focusItem().clearFocus()
                annot = self.scene().annotation_for_item(item)
                self.__undoStack.push(
                    commands.RemoveAnnotationCommand(self.__scheme, annot)
                )
        self.__undoStack.endMacro()

    def selectAll(self):
        # type: () -> None
        """
        Select all selectable items in the scheme.
        """
        for item in self.__scene.items():
            if item.flags() & QGraphicsItem.ItemIsSelectable:
                item.setSelected(True)

    def alignToGrid(self):
        # type: () -> None
        """
        Align nodes to a grid.
        """
        # TODO: The the current layout implementation is BAD (fix is urgent).
        if self.__scheme is None:
            return

        tile_size = 150
        tiles = {}  # type: Dict[Tuple[int, int], SchemeNode]
        nodes = sorted(self.__scheme.nodes, key=attrgetter("position"))

        if nodes:
            self.__undoStack.beginMacro(self.tr("Align To Grid"))

            for node in nodes:
                x, y = node.position
                x = int(round(float(x) / tile_size) * tile_size)
                y = int(round(float(y) / tile_size) * tile_size)
                while (x, y) in tiles:
                    x += tile_size

                self.__undoStack.push(
                    commands.MoveNodeCommand(self.__scheme, node,
                                             node.position, (x, y))
                )

                tiles[x, y] = node
                self.__scene.item_for_node(node).setPos(x, y)

            self.__undoStack.endMacro()

    def focusNode(self):
        # type: () -> Optional[SchemeNode]
        """
        Return the current focused :class:`.SchemeNode` or ``None`` if no
        node has focus.
        """
        focus = self.__scene.focusItem()
        node = None
        if isinstance(focus, items.NodeItem):
            try:
                node = self.__scene.node_for_item(focus)
            except KeyError:
                # in case the node has been removed but the scene was not
                # yet fully updated.
                node = None
        return node

    def selectedNodes(self):
        # type: () -> List[SchemeNode]
        """
        Return all selected :class:`.SchemeNode` items.
        """
        return list(map(self.scene().node_for_item,
                        self.scene().selected_node_items()))

    def selectedAnnotations(self):
        # type: () -> List[BaseSchemeAnnotation]
        """
        Return all selected :class:`.BaseSchemeAnnotation` items.
        """
        return list(map(self.scene().annotation_for_item,
                        self.scene().selected_annotation_items()))

    def openSelected(self):
        # type: () -> None
        """
        Open (show and raise) all widgets for the current selected nodes.
        """
        selected = self.selectedNodes()
        for node in selected:
            QCoreApplication.sendEvent(
                node, WorkflowEvent(WorkflowEvent.NodeActivateRequest))

    def editNodeTitle(self, node):
        # type: (SchemeNode) -> None
        """
        Edit (rename) the `node`'s title. Opens an input dialog.
        """
        name, ok = QInputDialog.getText(
            self, self.tr("Rename"),
            self.tr("Enter a new name for the '%s' widget") % node.title,
            text=node.title
        )

        if ok and self.__scheme is not None:
            self.__undoStack.push(
                commands.RenameNodeCommand(self.__scheme, node, node.title,
                                           name)
            )

    def __onCleanChanged(self, clean):
        # type: (bool) -> None
        if self.isWindowModified() != (not clean):
            self.setWindowModified(not clean)
            self.modificationChanged.emit(not clean)

    def changeEvent(self, event):
        # type: (QEvent) -> None
        if event.type() == QEvent.FontChange:
            self.__updateFont()
        elif event.type() == QEvent.PaletteChange:
            if self.__scene is not None:
                self.__scene.setPalette(self.palette())

        super().changeEvent(event)

    def eventFilter(self, obj, event):
        # type: (QObject, QEvent) -> bool
        # Filter the scene's drag/drop events.
        MIME_TYPE = "application/vnv.orange-canvas.registry.qualified-name"
        if obj is self.scene():
            etype = event.type()
            if etype == QEvent.GraphicsSceneDragEnter or \
                    etype == QEvent.GraphicsSceneDragMove:
                assert isinstance(event, QGraphicsSceneDragDropEvent)
                mime_data = event.mimeData()
                drop_target = None
                if mime_data.hasFormat(MIME_TYPE):
                    qname = bytes(mime_data.data(MIME_TYPE)).decode("ascii")
                    try:
                        desc = self.__registry.widget(qname)
                    except KeyError:
                        pass
                    else:
                        item = self.__scene.item_at(event.scenePos(), items.LinkItem)
                        link = self.scene().link_for_item(item) if item else None
                        if link is not None and can_insert_node(desc, link):
                            drop_target = item
                            drop_target.setHoverState(True)
                    event.acceptProposedAction()
                else:
                    event.ignore()

                if self.__dropTarget is not None and \
                        self.__dropTarget is not drop_target:
                    self.__dropTarget.setHoverState(False)
                self.__dropTarget = drop_target
                return True
            elif etype == QEvent.GraphicsSceneDragLeave:
                if self.__dropTarget is not None:
                    self.__dropTarget.setHoverState(False)
                    self.__dropTarget = None
            elif etype == QEvent.GraphicsSceneDrop:
                assert isinstance(event, QGraphicsSceneDragDropEvent)
                data = event.mimeData()
                qname = data.data(MIME_TYPE)
                try:
                    desc = self.__registry.widget(bytes(qname).decode("utf-8"))
                except KeyError:
                    log.error("Unknown qualified name '%s'", qname)
                else:
                    self.__statistics.set_node_type(UsageStatistics.NodeAddDrag)
                    pos = event.scenePos()
                    item = self.__scene.item_at(event.scenePos(), items.LinkItem)
                    link = self.scene().link_for_item(item) if item else None
                    if link and can_insert_node(desc, link):
                        node = self.newNodeHelper(desc, position=(pos.x(), pos.y()))
                        self.insertNode(node, link)
                    else:
                        self.createNewNode(desc, position=(pos.x(), pos.y()))
                return True

            elif etype == QEvent.GraphicsSceneMousePress:
                return self.sceneMousePressEvent(event)
            elif etype == QEvent.GraphicsSceneMouseMove:
                return self.sceneMouseMoveEvent(event)
            elif etype == QEvent.GraphicsSceneMouseRelease:
                return self.sceneMouseReleaseEvent(event)
            elif etype == QEvent.GraphicsSceneMouseDoubleClick:
                return self.sceneMouseDoubleClickEvent(event)
            elif etype == QEvent.KeyPress:
                return self.sceneKeyPressEvent(event)
            elif etype == QEvent.KeyRelease:
                return self.sceneKeyReleaseEvent(event)
            elif etype == QEvent.GraphicsSceneContextMenu:
                return self.sceneContextMenuEvent(event)

        elif obj is self.__scheme:
            if event.type() == QEvent.WhatsThisClicked:
                # Re post the event
                self.__showHelpFor(event.href())
            elif event.type() == WorkflowEvent.ActivateParentRequest:
                self.window().activateWindow()
                self.window().raise_()

        return super().eventFilter(obj, event)

    def sceneMousePressEvent(self, event):
        # type: (QGraphicsSceneMouseEvent) -> bool
        scene = self.__scene
        if scene.user_interaction_handler:
            return False

        pos = event.scenePos()

        anchor_item = scene.item_at(pos, items.NodeAnchorItem,
                                    buttons=Qt.LeftButton)
        if anchor_item and event.button() == Qt.LeftButton:
            # Start a new link starting at item
            scene.clearSelection()
            handler = interactions.NewLinkAction(self)
            self._setUserInteractionHandler(handler)
            return handler.mousePressEvent(event)

        any_item = scene.item_at(pos)
        if not any_item:
            self.__emptyClickButtons |= event.button()

        if not any_item and event.button() == Qt.LeftButton:
            # Create a RectangleSelectionAction but do not set in on the scene
            # just yet (instead wait for the mouse move event).
            handler = interactions.RectangleSelectionAction(self)
            rval = handler.mousePressEvent(event)
            if rval is True:
                self.__possibleSelectionHandler = handler
            return rval

        if any_item and event.button() == Qt.LeftButton:
            self.__possibleMouseItemsMove = True
            self.__itemsMoving.clear()
            self.__scene.node_item_position_changed.connect(
                self.__onNodePositionChanged
            )
            self.__annotationGeomChanged.mapped[QObject].connect(
                self.__onAnnotationGeometryChanged
            )

            set_enabled_all(self.__disruptiveActions, False)

        return False

    def sceneMouseMoveEvent(self, event):
        # type: (QGraphicsSceneMouseEvent) -> bool
        scene = self.__scene
        if scene.user_interaction_handler:
            return False

        if self.__emptyClickButtons & Qt.LeftButton and \
                event.buttons() & Qt.LeftButton and \
                self.__possibleSelectionHandler:
            # Set the RectangleSelection (initialized in mousePressEvent)
            # on the scene
            handler = self.__possibleSelectionHandler
            self._setUserInteractionHandler(handler)
            self.__possibleSelectionHandler = None
            return handler.mouseMoveEvent(event)

        return False

    def sceneMouseReleaseEvent(self, event):
        # type: (QGraphicsSceneMouseEvent) -> bool
        scene = self.__scene
        if scene.user_interaction_handler:
            return False

        if event.button() == Qt.LeftButton and self.__possibleMouseItemsMove:
            self.__possibleMouseItemsMove = False
            self.__scene.node_item_position_changed.disconnect(
                self.__onNodePositionChanged
            )
            self.__annotationGeomChanged.mapped[QObject].disconnect(
                self.__onAnnotationGeometryChanged
            )

            set_enabled_all(self.__disruptiveActions, True)

            if self.__itemsMoving:
                self.__scene.mouseReleaseEvent(event)
                scheme = self.__scheme
                assert scheme is not None
                stack = self.undoStack()
                stack.beginMacro(self.tr("Move"))
                for scheme_item, (old, new) in self.__itemsMoving.items():
                    if isinstance(scheme_item, SchemeNode):
                        command = commands.MoveNodeCommand(
                            scheme, scheme_item, old, new
                        )
                    elif isinstance(scheme_item, BaseSchemeAnnotation):
                        command = commands.AnnotationGeometryChange(
                            scheme, scheme_item, old, new
                        )
                    else:
                        continue

                    stack.push(command)
                stack.endMacro()

                self.__itemsMoving.clear()
                return True
        elif event.button() == Qt.LeftButton:
            self.__possibleSelectionHandler = None

        return False

    def sceneMouseDoubleClickEvent(self, event):
        # type: (QGraphicsSceneMouseEvent) -> bool
        scene = self.__scene
        if scene.user_interaction_handler:
            return False

        item = scene.item_at(event.scenePos())
        if not item and self.__quickMenuTriggers & \
                SchemeEditWidget.DoubleClicked:
            # Double click on an empty spot
            # Create a new node using QuickMenu
            action = interactions.NewNodeAction(self)
            with ExitStack() as stack:
                stack.enter_context(disabled(self.__undoAction))
                stack.enter_context(disabled(self.__redoAction))
                action.create_new(event.screenPos())

            event.accept()
            return True

        item = scene.item_at(event.scenePos(), items.LinkItem,
                             buttons=Qt.LeftButton)

        if item is not None and event.button() == Qt.LeftButton:
            link = self.scene().link_for_item(item)
            action = interactions.EditNodeLinksAction(self, link.source_node,
                                                      link.sink_node)
            action.edit_links()
            event.accept()
            return True

        return False

    def sceneKeyPressEvent(self, event):
        # type: (QKeyEvent) -> bool
        scene = self.__scene
        if scene.user_interaction_handler:
            return False

        # If a QGraphicsItem is in text editing mode, don't interrupt it
        focusItem = scene.focusItem()
        if focusItem and isinstance(focusItem, QGraphicsTextItem) and \
                focusItem.textInteractionFlags() & Qt.TextEditable:
            return False

        # If the mouse is not over out view
        if not self.view().underMouse():
            return False

        handler = None
        searchText = ""
        if (event.key() == Qt.Key_Space and \
                self.__quickMenuTriggers & SchemeEditWidget.SpaceKey):
            handler = interactions.NewNodeAction(self)

        elif len(event.text()) and \
                self.__quickMenuTriggers & SchemeEditWidget.AnyKey and \
                is_printable(event.text()[0]):
            handler = interactions.NewNodeAction(self)
            searchText = event.text()

            # TODO: set the search text to event.text() and set focus on the
            # search line

        if handler is not None:
            # Control + Backspace (remove widget action on Mac OSX) conflicts
            # with the 'Clear text' action in the search widget (there might
            # be selected items in the canvas), so we disable the
            # remove widget action so the text editing follows standard
            # 'look and feel'
            with ExitStack() as stack:
                stack.enter_context(disabled(self.__removeSelectedAction))
                stack.enter_context(disabled(self.__undoAction))
                stack.enter_context(disabled(self.__redoAction))
                handler.create_new(QCursor.pos(), searchText)

            event.accept()
            return True

        return False

    def sceneKeyReleaseEvent(self, event):
        # type: (QKeyEvent) -> bool
        return False

    def sceneContextMenuEvent(self, event):
        # type: (QGraphicsSceneContextMenuEvent) -> bool
        scenePos = event.scenePos()
        globalPos = event.screenPos()

        item = self.scene().item_at(scenePos, items.NodeItem)
        if item is not None:
            node = self.scene().node_for_item(item)  # type: SchemeNode
            actions = []  # type: List[QAction]
            manager = self.widgetManager()
            if manager is not None:
                actions = manager.actions_for_context_menu(node)

            # TODO: Inspect actions for all selected nodes and merge 'same'
            #       actions (by name)
            if actions and len(self.selectedNodes()) == 1:
                # The node has extra actions for the context menu.
                # Copy the default context menu and append the extra actions.
                menu = QMenu(self)
                for a in self.__widgetMenu.actions():
                    menu.addAction(a)
                menu.addSeparator()
                for a in actions:
                    menu.addAction(a)
                menu.setAttribute(Qt.WA_DeleteOnClose)
            else:
                menu = self.__widgetMenu
            menu.popup(globalPos)
            return True

        item = self.scene().item_at(scenePos, items.LinkItem,
                                    buttons=Qt.RightButton)
        if item is not None:
            link = self.scene().link_for_item(item)
            self.__linkEnableAction.setChecked(link.enabled)
            self.__contextMenuTarget = link
            self.__linkMenu.popup(globalPos)
            return True

        item = self.scene().item_at(scenePos)
        if not item and \
                self.__quickMenuTriggers & SchemeEditWidget.RightClicked:
            action = interactions.NewNodeAction(self)

            with ExitStack() as stack:
                stack.enter_context(disabled(self.__undoAction))
                stack.enter_context(disabled(self.__redoAction))
                action.create_new(globalPos)
            return True

        return False

    def _setUserInteractionHandler(self, handler):
        # type: (Optional[interactions.UserInteraction]) -> None
        """
        Helper method for setting the user interaction handlers.
        """
        if self.__scene.user_interaction_handler:
            if isinstance(self.__scene.user_interaction_handler,
                          (interactions.ResizeArrowAnnotation,
                           interactions.ResizeTextAnnotation)):
                self.__scene.user_interaction_handler.commit()

            self.__scene.user_interaction_handler.ended.disconnect(
                self.__onInteractionEnded
            )

        if handler:
            handler.ended.connect(self.__onInteractionEnded)
            # Disable actions which could change the model
            set_enabled_all(self.__disruptiveActions, False)

        self.__scene.set_user_interaction_handler(handler)

    def __onInteractionEnded(self):
        # type: () -> None
        self.sender().ended.disconnect(self.__onInteractionEnded)
        set_enabled_all(self.__disruptiveActions, True)

    def __onSelectionChanged(self):
        # type: () -> None
        nodes = self.selectedNodes()
        annotations = self.selectedAnnotations()

        self.__openSelectedAction.setEnabled(bool(nodes))
        self.__removeSelectedAction.setEnabled(
            bool(nodes) or bool(annotations)
        )

        self.__helpAction.setEnabled(len(nodes) == 1)
        self.__renameAction.setEnabled(len(nodes) == 1)
        self.__duplicateSelectedAction.setEnabled(bool(nodes))

        if len(nodes) > 1:
            self.__openSelectedAction.setText(self.tr("Open All"))
        else:
            self.__openSelectedAction.setText(self.tr("Open"))

        if len(nodes) + len(annotations) > 1:
            self.__removeSelectedAction.setText(self.tr("Remove All"))
        else:
            self.__removeSelectedAction.setText(self.tr("Remove"))

        if len(nodes) == 0:
            self.__openSelectedAction.setText(self.tr("Open"))
            self.__removeSelectedAction.setText(self.tr("Remove"))

        focus = self.focusNode()
        if focus is not None:
            desc = focus.description
            tip = whats_this_helper(desc, include_more_link=True)
        else:
            tip = ""

        if tip != self.__quickTip:
            self.__quickTip = tip
            ev = QuickHelpTipEvent("", self.__quickTip,
                                   priority=QuickHelpTipEvent.Permanent)

            QCoreApplication.sendEvent(self, ev)

    def __onNodeActivate(self, item):
        # type: (items.NodeItem) -> None
        node = self.__scene.node_for_item(item)
        QCoreApplication.sendEvent(
            node, WorkflowEvent(WorkflowEvent.NodeActivateRequest))

    def __onNodePositionChanged(self, item, pos):
        # type: (items.NodeItem, QPointF) -> None
        node = self.__scene.node_for_item(item)
        new = (pos.x(), pos.y())
        if node not in self.__itemsMoving:
            self.__itemsMoving[node] = (node.position, new)
        else:
            old, _ = self.__itemsMoving[node]
            self.__itemsMoving[node] = (old, new)

    def __onAnnotationGeometryChanged(self, item):
        # type: (AnnotationItem) -> None
        annot = self.scene().annotation_for_item(item)
        if annot not in self.__itemsMoving:
            self.__itemsMoving[annot] = (annot.geometry,
                                         geometry_from_annotation_item(item))
        else:
            old, _ = self.__itemsMoving[annot]
            self.__itemsMoving[annot] = (old,
                                         geometry_from_annotation_item(item))

    def __onAnnotationAdded(self, item):
        # type: (AnnotationItem) -> None
        log.debug("Annotation added (%r)", item)
        item.setFlag(QGraphicsItem.ItemIsSelectable)
        item.setFlag(QGraphicsItem.ItemIsMovable)
        item.setFlag(QGraphicsItem.ItemIsFocusable)

        if isinstance(item, items.ArrowAnnotation):
            pass
        elif isinstance(item, items.TextAnnotation):
            # Make the annotation editable.
            item.setTextInteractionFlags(Qt.TextEditorInteraction)

            self.__editFinishedMapper.setMapping(item, item)
            item.editingFinished.connect(
                self.__editFinishedMapper.map
            )

        self.__annotationGeomChanged.setMapping(item, item)
        item.geometryChanged.connect(
            self.__annotationGeomChanged.map
        )

    def __onAnnotationRemoved(self, item):
        # type: (AnnotationItem) -> None
        log.debug("Annotation removed (%r)", item)
        if isinstance(item, items.ArrowAnnotation):
            pass
        elif isinstance(item, items.TextAnnotation):
            item.editingFinished.disconnect(
                self.__editFinishedMapper.map
            )

        self.__annotationGeomChanged.removeMappings(item)
        item.geometryChanged.disconnect(
            self.__annotationGeomChanged.map
        )

    def __onFocusItemChanged(self, newFocusItem, oldFocusItem):
        # type: (Optional[QGraphicsItem], Optional[QGraphicsItem]) -> None

        if isinstance(oldFocusItem, items.annotationitem.Annotation):
            self.__endControlPointEdit()
        if isinstance(newFocusItem, items.annotationitem.Annotation):
            if not self.__scene.user_interaction_handler:
                self.__startControlPointEdit(newFocusItem)

    def __onEditingFinished(self, item):
        # type: (items.TextAnnotation) -> None
        """
        Text annotation editing has finished.
        """
        annot = self.__scene.annotation_for_item(item)
        assert isinstance(annot, SchemeTextAnnotation)
        content_type = item.contentType()
        content = item.content()

        if annot.text != content or annot.content_type != content_type:
            assert self.__scheme is not None
            self.__undoStack.push(
                commands.TextChangeCommand(
                    self.__scheme, annot,
                    annot.text, annot.content_type,
                    content, content_type
                )
            )

    def __toggleNewArrowAnnotation(self, checked):
        # type: (bool) -> None
        if self.__newTextAnnotationAction.isChecked():
            # Uncheck the text annotation action if needed.
            self.__newTextAnnotationAction.setChecked(not checked)

        action = self.__newArrowAnnotationAction

        if not checked:
            # The action was unchecked (canceled by the user)
            handler = self.__scene.user_interaction_handler
            if isinstance(handler, interactions.NewArrowAnnotation):
                # Cancel the interaction and restore the state
                handler.ended.disconnect(action.toggle)
                handler.cancel(interactions.UserInteraction.UserCancelReason)
                log.info("Canceled new arrow annotation")

        else:
            handler = interactions.NewArrowAnnotation(self)
            checked_action = self.__arrowColorActionGroup.checkedAction()
            handler.setColor(checked_action.data())

            handler.ended.connect(action.toggle)

            self._setUserInteractionHandler(handler)

    def __onFontSizeTriggered(self, action):
        # type: (QAction) -> None
        if not self.__newTextAnnotationAction.isChecked():
            # When selecting from the (font size) menu the 'Text'
            # action does not get triggered automatically.
            self.__newTextAnnotationAction.trigger()
        else:
            # Update the preferred font on the interaction handler.
            handler = self.__scene.user_interaction_handler
            if isinstance(handler, interactions.NewTextAnnotation):
                handler.setFont(action.font())

    def __toggleNewTextAnnotation(self, checked):
        # type: (bool) -> None
        if self.__newArrowAnnotationAction.isChecked():
            # Uncheck the arrow annotation if needed.
            self.__newArrowAnnotationAction.setChecked(not checked)

        action = self.__newTextAnnotationAction

        if not checked:
            # The action was unchecked (canceled by the user)
            handler = self.__scene.user_interaction_handler
            if isinstance(handler, interactions.NewTextAnnotation):
                # cancel the interaction and restore the state
                handler.ended.disconnect(action.toggle)
                handler.cancel(interactions.UserInteraction.UserCancelReason)
                log.info("Canceled new text annotation")

        else:
            handler = interactions.NewTextAnnotation(self)
            checked_action = self.__fontActionGroup.checkedAction()
            handler.setFont(checked_action.font())

            handler.ended.connect(action.toggle)

            self._setUserInteractionHandler(handler)

    def __onArrowColorTriggered(self, action):
        # type: (QAction) -> None
        if not self.__newArrowAnnotationAction.isChecked():
            # When selecting from the (color) menu the 'Arrow'
            # action does not get triggered automatically.
            self.__newArrowAnnotationAction.trigger()
        else:
            # Update the preferred color on the interaction handler
            handler = self.__scene.user_interaction_handler
            if isinstance(handler, interactions.NewArrowAnnotation):
                handler.setColor(action.data())

    def __onRenameAction(self):
        # type: () -> None
        """
        Rename was requested for the selected widget.
        """
        selected = self.selectedNodes()
        if len(selected) == 1:
            self.editNodeTitle(selected[0])

    def __onHelpAction(self):
        # type: () -> None
        """
        Help was requested for the selected widget.
        """
        nodes = self.selectedNodes()
        help_url = None
        if len(nodes) == 1:
            node = nodes[0]
            desc = node.description

            help_url = "help://search?" + urlencode({"id": desc.qualified_name})
            self.__showHelpFor(help_url)

    def __showHelpFor(self, help_url):
        # type: (str) -> None
        """
        Show help for an "help" url.
        """
        # Notify the parent chain and let them respond
        ev = QWhatsThisClickedEvent(help_url)
        handled = QCoreApplication.sendEvent(self, ev)

        if not handled:
            message_information(
                self.tr("Sorry there is no documentation available for "
                        "this widget."),
                parent=self)

    def __toggleLinkEnabled(self, enabled):
        # type: (bool) -> None
        """
        Link 'enabled' state was toggled in the context menu.
        """
        if self.__contextMenuTarget:
            link = self.__contextMenuTarget
            command = commands.SetAttrCommand(
                link, "enabled", enabled, name=self.tr("Set enabled"),
            )
            self.__undoStack.push(command)

    def __linkRemove(self):
        # type: () -> None
        """
        Remove link was requested from the context menu.
        """
        if self.__contextMenuTarget:
            self.removeLink(self.__contextMenuTarget)

    def __linkReset(self):
        # type: () -> None
        """
        Link reset from the context menu was requested.
        """
        if self.__contextMenuTarget:
            link = self.__contextMenuTarget
            action = interactions.EditNodeLinksAction(
                self, link.source_node, link.sink_node
            )
            action.edit_links()

    def __nodeInsert(self):
        # type: () -> None
        """
        Node insert was requested from the context menu.
        """
        if not self.__contextMenuTarget:
            return

        original_link = self.__contextMenuTarget
        source_node = original_link.source_node
        sink_node = original_link.sink_node

        def filterFunc(index):
            desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
            if isinstance(desc, WidgetDescription):
                return can_insert_node(desc, original_link)
            else:
                return False

        x = (source_node.position[0] + sink_node.position[0]) / 2
        y = (source_node.position[1] + sink_node.position[1]) / 2

        menu = self.quickMenu()
        menu.setFilterFunc(filterFunc)
        menu.setSortingFunc(None)

        view = self.view()
        try:
            action = menu.exec_(view.mapToGlobal(view.mapFromScene(QPoint(x, y))))
        finally:
            menu.setFilterFunc(None)

        if action:
            item = action.property("item")
            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
        else:
            return

        if can_insert_node(desc, original_link):
            new_node = self.newNodeHelper(desc, position=(x, y))
            self.insertNode(new_node, original_link)
        else:
            log.info("Cannot insert node: links not possible.")

    def __duplicateSelected(self):
        # type: () -> None
        """
        Duplicate currently selected nodes.
        """
        def copy_node(node):
            # type: (SchemeNode) -> SchemeNode
            x, y = node.position
            return SchemeNode(
                node.description, node.title, position=(x + 20, y + 20),
                properties=copy.deepcopy(node.properties))

        def copy_link(link, source=None, sink=None):
            # type: (SchemeLink, Optional[SchemeNode], Optional[SchemeNode]) -> SchemeLink
            source = link.source_node if source is None else source
            sink = link.sink_node if sink is None else sink
            return SchemeLink(
                source, link.source_channel,
                sink, link.sink_channel,
                enabled=link.enabled,
                properties=copy.deepcopy(link.properties))

        scheme = self.scheme()
        if scheme is None:
            return

        # ensure up to date node properties (settings)
        scheme.sync_node_properties()

        selection = self.selectedNodes()

        links = [link for link in scheme.links
                 if link.source_node in selection and
                    link.sink_node in selection]
        nodedups = [copy_node(node) for node in selection]
        allnames = {node.title for node in scheme.nodes + nodedups}
        for nodedup in nodedups:
            nodedup.title = uniquify(
                nodedup.title, allnames, pattern="{item} ({_})", start=1)

        node_to_dup = dict(zip(selection, nodedups))

        linkdups = [copy_link(link, source=node_to_dup[link.source_node],
                              sink=node_to_dup[link.sink_node])
                    for link in links]

        command = QUndoCommand(self.tr("Duplicate"))
        macrocommands = []
        for nodedup in nodedups:
            macrocommands.append(
                commands.AddNodeCommand(scheme, nodedup, parent=command))
        for linkdup in linkdups:
            macrocommands.append(
                commands.AddLinkCommand(scheme, linkdup, parent=command))

        self.__undoStack.push(command)
        scene = self.__scene

        for node in selection:
            item = scene.item_for_node(node)
            item.setSelected(False)

        for node in nodedups:
            item = scene.item_for_node(node)
            item.setSelected(True)

    def __startControlPointEdit(self, item):
        # type: (items.annotationitem.Annotation) -> None
        """
        Start a control point edit interaction for `item`.
        """
        if isinstance(item, items.ArrowAnnotation):
            handler = interactions.ResizeArrowAnnotation(self)
        elif isinstance(item, items.TextAnnotation):
            handler = interactions.ResizeTextAnnotation(self)
        else:
            log.warning("Unknown annotation item type %r" % item)
            return

        handler.editItem(item)
        self._setUserInteractionHandler(handler)

        log.info("Control point editing started (%r)." % item)

    def __endControlPointEdit(self):
        # type: () -> None
        """
        End the current control point edit interaction.
        """
        handler = self.__scene.user_interaction_handler
        if isinstance(handler, (interactions.ResizeArrowAnnotation,
                                interactions.ResizeTextAnnotation)) and \
                not handler.isFinished() and not handler.isCanceled():
            handler.commit()
            handler.end()

            log.info("Control point editing finished.")

    def __updateFont(self):
        # type: () -> None
        """
        Update the font for the "Text size' menu and the default font
        used in the `CanvasScene`.
        """
        actions = self.__fontActionGroup.actions()
        font = self.font()
        for action in actions:
            size = action.font().pixelSize()
            action_font = QFont(font)
            action_font.setPixelSize(size)
            action.setFont(action_font)

        if self.__scene:
            self.__scene.setFont(font)

    def __signalManagerStateChanged(self, state):
        # type: (RuntimeState) -> None
        if state == RuntimeState.Running:
            role = QPalette.Base
        else:
            role = QPalette.Window
        self.__view.viewport().setBackgroundRole(role)

    def __saveWindowGroup(self):
        # type: () -> None
        """Run a 'Save Window Group' dialog"""
        workflow = self.__scheme
        manager = self.__widgetManager
        if manager is None or workflow is None:
            return
        state = manager.save_window_state()
        presets = workflow.window_group_presets()
        items = [g.name for g in presets]
        default = [i for i, g in enumerate(presets) if g.default]
        dlg = SaveWindowGroup(
            self, windowTitle="Save Group as...")
        dlg.setWindowModality(Qt.ApplicationModal)
        dlg.setItems(items)
        if default:
            dlg.setDefaultIndex(default[0])

        menu = self.__windowGroupsAction.menu()  # type: QMenu
        group = self.__windowGroupsActionGroup

        def store_group():
            text = dlg.selectedText()
            default = dlg.isDefaultChecked()
            actions = group.actions()  # type: List[QAction]
            try:
                idx = items.index(text)
            except ValueError:
                idx = -1
            newpresets = [copy.copy(g) for g in presets]  # shallow copy
            newpreset = Scheme.WindowGroup(text, default, state)
            if idx == -1:
                # new group slot
                newpresets.append(newpreset)
                action = QAction(text, menu)
                action.setShortcut(
                    QKeySequence("Meta+P, Ctrl+{}".format(len(newpresets)))
                )
                oldpreset = None
            else:
                newpresets[idx] = newpreset

                action = actions[idx]
                # store old state for undo
                oldpreset = presets[idx]

            if newpreset.default:
                idx_ = idx if idx >= 0 else len(newpresets) - 1
                for g in newpresets[:idx_] + newpresets[idx_ + 1:]:
                    g.default = False
            sep = menu.findChild(QAction, "groups-separator")
            assert isinstance(sep, QAction) and sep.isSeparator()

            def redo():
                action.setData(newpreset)
                workflow.set_window_group_presets(newpresets)
                if idx == -1:
                    group.addAction(action)
                    menu.insertAction(sep, action)

            def undo():
                action.setData(oldpreset)
                workflow.set_window_group_presets(presets)
                if idx == -1:
                    group.removeAction(action)
                    menu.removeAction(action)
            if idx == -1:
                text = "Store Window Group"
            else:
                text = "Update Window Group"
            self.__undoStack.push(
                commands.SimpleUndoCommand(redo, undo, text)
            )
        dlg.accepted.connect(store_group)
        dlg.show()
        dlg.raise_()

    def __activateWindowGroup(self, action):
        # type: (QAction) -> None
        data = action.data()  # type: Scheme.WindowGroup
        wm = self.__widgetManager
        if wm is not None:
            wm.activate_window_group(data)

    def __clearWindowGroups(self):
        # type: () -> None
        workflow = self.__scheme
        if workflow is None:
            return
        presets = workflow.window_group_presets()
        menu = self.__windowGroupsAction.menu()  # type: QMenu
        group = self.__windowGroupsActionGroup
        actions = group.actions()

        def redo():
            workflow.set_window_group_presets([])
            for action in reversed(actions):
                group.removeAction(action)
                menu.removeAction(action)

        def undo():
            workflow.set_window_group_presets(presets)
            sep = menu.findChild(QAction, "groups-separator")
            for action in actions:
                group.addAction(action)
                menu.insertAction(sep, action)

        self.__undoStack.push(
            commands.SimpleUndoCommand(redo, undo, "Delete All Window Groups")
        )

    def __raiseToFont(self):
        # Raise current visible widgets to front
        wm = self.__widgetManager
        if wm is not None:
            wm.raise_widgets_to_front()

    def activateDefaultWindowGroup(self):
        # type: () -> bool
        """
        Activate the default window group if one exists.

        Return `True` if a default group exists and was activated; `False` if
        not.
        """
        for action in self.__windowGroupsActionGroup.actions():
            g = action.data()
            if g.default:
                action.trigger()
                return True
        return False

    def widgetManager(self):
        # type: () -> Optional[WidgetManager]
        """
        Return the widget manager.
        """
        return self.__widgetManager


class SaveWindowGroup(QDialog):
    """
    A dialog for saving window groups.

    The user can select an existing group to overwrite or enter a new group
    name.
    """
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        layout = QVBoxLayout()
        form = QFormLayout(
            fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow)
        layout.addLayout(form)
        self._combobox = cb = QComboBox(
            editable=True, minimumContentsLength=16,
            sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLength,
            insertPolicy=QComboBox.NoInsert,
        )
        cb.currentIndexChanged.connect(self.__currentIndexChanged)
        # default text if no items are present
        cb.setEditText(self.tr("Window Group 1"))
        cb.lineEdit().selectAll()
        form.addRow(self.tr("Save As:"), cb)
        self._checkbox = check = QCheckBox(
            self.tr("Use as default"),
            toolTip="Automatically use this preset when opening the workflow."
        )
        form.setWidget(1, QFormLayout.FieldRole, check)
        bb = QDialogButtonBox(
            standardButtons=QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        bb.accepted.connect(self.__accept_check)
        bb.rejected.connect(self.reject)
        layout.addWidget(bb)
        layout.setSizeConstraint(QVBoxLayout.SetFixedSize)
        self.setLayout(layout)
        self.setWhatsThis(
            "Save the current open widgets' window arrangement to the "
            "workflow view presets."
        )
        cb.setFocus(Qt.NoFocusReason)

    def __currentIndexChanged(self, idx):
        # type: (int) -> None
        state = self._combobox.itemData(idx, Qt.UserRole + 1)
        if not isinstance(state, bool):
            state = False
        self._checkbox.setChecked(state)

    def __accept_check(self):
        # type: () -> None
        cb = self._combobox
        text = cb.currentText()
        if cb.findText(text) == -1:
            self.accept()
            return
        # Ask for overwrite confirmation
        mb = QMessageBox(
            self, windowTitle=self.tr("Confirm Overwrite"),
            icon=QMessageBox.Question,
            standardButtons=QMessageBox.Yes | QMessageBox.Cancel,
            text=self.tr("The window group '{}' already exists. Do you want " +
                         "to replace it?").format(text),
        )
        mb.setDefaultButton(QMessageBox.Yes)
        mb.setEscapeButton(QMessageBox.Cancel)
        mb.setWindowModality(Qt.WindowModal)
        button = mb.button(QMessageBox.Yes)
        button.setText(self.tr("Replace"))

        def on_finished(status):  # type: (int) -> None
            if status == QMessageBox.Yes:
                self.accept()
        mb.finished.connect(on_finished)
        mb.show()

    def setItems(self, items):
        # type: (List[str]) -> None
        """Set a list of existing items/names to present to the user"""
        self._combobox.clear()
        self._combobox.addItems(items)
        if items:
            self._combobox.setCurrentIndex(len(items) - 1)

    def setDefaultIndex(self, idx):
        # type: (int) -> None
        self._combobox.setItemData(idx, True, Qt.UserRole + 1)
        self._checkbox.setChecked(self._combobox.currentIndex() == idx)

    def selectedText(self):
        # type: () -> str
        """Return the current entered text."""
        return self._combobox.currentText()

    def isDefaultChecked(self):
        # type: () -> bool
        """Return the state of the 'Use as default' check box."""
        return self._checkbox.isChecked()


def geometry_from_annotation_item(item):
    if isinstance(item, items.ArrowAnnotation):
        line = item.line()
        p1 = item.mapToScene(line.p1())
        p2 = item.mapToScene(line.p2())
        return ((p1.x(), p1.y()), (p2.x(), p2.y()))
    elif isinstance(item, items.TextAnnotation):
        geom = item.geometry()
        return (geom.x(), geom.y(), geom.width(), geom.height())


def mouse_drag_distance(event, button=Qt.LeftButton):
    # type: (QGraphicsSceneMouseEvent, Qt.MouseButton) -> float
    """
    Return the (manhattan) distance between the mouse position
    when the `button` was pressed and the current mouse position.
    """
    diff = (event.buttonDownScreenPos(button) - event.screenPos())
    return diff.manhattanLength()


def set_enabled_all(objects, enable):
    # type: (Iterable[Any], bool) -> None
    """
    Set `enabled` properties on all objects (objects with `setEnabled` method).
    """
    for obj in objects:
        obj.setEnabled(enable)


# All control character categories.
_control = set(["Cc", "Cf", "Cs", "Co", "Cn"])


def is_printable(unichar):
    # type: (str) -> bool
    """
    Return True if the unicode character `unichar` is a printable character.
    """
    return unicodedata.category(unichar) not in _control


def node_properties(scheme):
    # type: (Scheme) -> List[Dict[str, Any]]
    scheme.sync_node_properties()
    return [dict(node.properties) for node in scheme.nodes]


def can_insert_node(new_node_desc, original_link):
    # type: (WidgetDescription, SchemeLink) -> bool
    return any(scheme.compatible_channels(original_link.source_channel, input)
               for input in new_node_desc.inputs) and \
           any(scheme.compatible_channels(output, original_link.sink_channel)
               for output in new_node_desc.outputs)


def uniquify(item, names, pattern="{item}-{_}", start=0):
    # type: (str, Container[str], str, int) -> str
    candidates = (pattern.format(item=item, _=i)
                  for i in itertools.count(start))
    candidates = itertools.dropwhile(lambda item: item in names, candidates)
    return next(candidates)
