Source code for orangecanvas.document.schemeedit
"""
====================
Scheme Editor Widget
====================
"""
import io
import logging
import itertools
import unicodedata
import copy
from operator import attrgetter
from urllib.parse import urlencode
from contextlib import ExitStack, contextmanager
from typing import (
List, Tuple, Optional, Container, Dict, Any, Iterable, Generator
)
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,
QApplication
)
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,
QMimeData
)
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
# Private MIME type for clipboard contents
MimeTypeWorkflowFragment = "application/vnd.{}-ows-fragment+xml".format(__name__)
log = logging.getLogger(__name__)
DuplicateOffset = QPointF(0, 120)
class NoWorkflowError(RuntimeError):
def __init__(self, message: str = "No workflow model is set", **kwargs):
super().__init__(message, *kwargs)
[docs]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)
# Preferred position for paste command. Updated on every mouse button
# press and copy operation.
self.__pasteOrigin = QPointF(20, 20)
# 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.__copySelectedAction)
self.__editMenu.addAction(self.__pasteAction)
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.addAction(self.__copySelectedAction)
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"), self,
objectName="duplicate-action",
enabled=False,
shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_D),
triggered=self.__duplicateSelected,
)
self.__copySelectedAction = QAction(
self.tr("Copy"), self,
objectName="copy-action",
enabled=False,
shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_C),
triggered=self.__copyToClipboard,
)
self.__pasteAction = QAction(
self.tr("Paste"), self,
objectName="paste-action",
enabled=clipboard_has_format(MimeTypeWorkflowFragment),
shortcut=QKeySequence(Qt.ControlModifier + Qt.Key_V),
triggered=self.__pasteFromClipboard,
)
QApplication.clipboard().dataChanged.connect(
self.__updatePasteActionState
)
self.addActions([
self.__newTextAnnotationAction,
self.__newArrowAnnotationAction,
self.__linkEnableAction,
self.__linkRemoveAction,
self.__nodeInsertAction,
self.__linkResetAction,
self.__duplicateSelectedAction,
self.__copySelectedAction,
self.__pasteAction
])
# Actions which should be disabled while a multistep
# interaction is in progress.
self.__disruptiveActions = [
self.__undoAction,
self.__redoAction,
self.__removeSelectedAction,
self.__selectAllAction,
self.__duplicateSelectedAction,
self.__copySelectedAction,
self.__pasteAction
]
#: 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()
[docs] 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]
[docs] def isModified(self):
# type: () -> bool
"""
Is the document is a modified state.
"""
return self.__modified or not self.__undoStack.isClean()
[docs] 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)
[docs] 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
[docs] 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
[docs] def quickMenuTriggers(self):
# type: () -> int
"""
Return quick menu trigger flags.
"""
return self.__quickMenuTriggers
[docs] 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)
[docs] def channelNamesVisible(self):
# type: () -> bool
"""
Return the channel name visibility state.
"""
return self.__channelNamesVisible
[docs] 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)
[docs] def nodeAnimationEnabled(self):
# type () -> bool
"""
Return the node item animation enabled state.
"""
return self.__nodeAnimationEnabled
[docs] def undoStack(self):
# type: () -> QUndoStack
"""
Return the undo stack.
"""
return self.__undoStack
[docs] 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)
[docs] def path(self):
# type: () -> str
"""
Return the path associated with the scheme
"""
return self.__path
[docs] 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)
[docs] 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)
[docs] def scheme(self):
# type: () -> Optional[Scheme]
"""
Return the :class:`~.scheme.Scheme` edited by the widget.
"""
return self.__scheme
[docs] def scene(self):
# type: () -> QGraphicsScene
"""
Return the :class:`QGraphicsScene` instance used to display the
current scheme.
"""
return self.__scene
[docs] def view(self):
# type: () -> QGraphicsView
"""
Return the :class:`QGraphicsView` instance used to display the
current scene.
"""
return self.__view
[docs] def suggestions(self):
"""
Return the widget suggestion prediction class.
"""
return self.__suggestions
[docs] 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
[docs] 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
[docs] def setTitle(self, title):
# type: (str) -> None
"""
Set the scheme title.
"""
self.__undoStack.push(
commands.SetAttrCommand(self.__scheme, "title", title)
)
[docs] def setDescription(self, description):
# type: (str) -> None
"""
Set the scheme description string.
"""
self.__undoStack.push(
commands.SetAttrCommand(self.__scheme, "description", description)
)
[docs] 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_add(node.description.name)
command = commands.AddNodeCommand(self.__scheme, node)
self.__undoStack.push(command)
[docs] 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
[docs] 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)
[docs] 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)
[docs] 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
[docs] 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)
[docs] 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)
)
[docs] 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)
[docs] 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)
[docs] 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))
self.usageStatistics().log_node_add(new_node.description.name)
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)
[docs] 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)
[docs] 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)
[docs] 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)
)
statistics = self.usageStatistics()
statistics.set_action_type(UsageStatistics.NodeRemove)
statistics.log_node_remove(node.description.name)
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()
[docs] 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)
[docs] 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()
[docs] 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
[docs] 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()))
[docs] 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()))
[docs] 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))
[docs] 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)
[docs] 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)
[docs] 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_action_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.usageStatistics().set_action_type(UsageStatistics.NodeAddInsertDrag)
self.insertNode(node, link)
else:
self.createNewNode(desc, position=(pos.x(), pos.y()))
return True
elif etype == QEvent.GraphicsSceneMousePress:
self.__pasteOrigin = event.scenePos()
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)
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 disable_undo_stack_actions(
self.__undoAction, self.__redoAction, self.__undoStack):
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(
disable_undo_stack_actions(
self.__undoAction, self.__redoAction, self.__undoStack)
)
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 disable_undo_stack_actions(
self.__undoAction, self.__redoAction, self.__undoStack):
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))
self.__copySelectedAction.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)
statistics = self.usageStatistics()
statistics.set_action_type(UsageStatistics.LinkRemove)
statistics.log_link_remove(self.__contextMenuTarget.source_node.description.name,
self.__contextMenuTarget.sink_node.description.name,
self.__contextMenuTarget.source_channel.name,
self.__contextMenuTarget.sink_channel.name)
statistics.clear_action_type()
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.usageStatistics().set_action_type(UsageStatistics.NodeAddInsertMenu)
self.insertNode(new_node, original_link)
else:
log.info("Cannot insert node: links not possible.")
def __duplicateSelected(self):
# type: () -> None
"""
Duplicate currently selected nodes.
"""
nodedups, linkdups = self.__copySelected()
if not nodedups:
return
pos = nodes_top_left(nodedups)
self.__paste(nodedups, linkdups, pos + DuplicateOffset,
commandname=self.tr("Duplicate"))
def __copyToClipboard(self):
"""
Copy currently selected nodes to system clipboard.
"""
cb = QApplication.clipboard()
selected = self.__copySelected()
nodes, links = selected
if not nodes:
return
s = Scheme()
for n in nodes:
s.add_node(n)
for e in links:
s.add_link(e)
buff = io.BytesIO()
try:
s.save_to(buff, pickle_fallback=True)
except Exception:
log.error("copyToClipboard:", exc_info=True)
QApplication.beep()
return
mime = QMimeData()
mime.setData(MimeTypeWorkflowFragment, buff.getvalue())
cb.setMimeData(mime)
self.__pasteOrigin = nodes_top_left(nodes) + DuplicateOffset
def __updatePasteActionState(self):
self.__pasteAction.setEnabled(
clipboard_has_format(MimeTypeWorkflowFragment)
)
def __copySelected(self):
"""
Return a deep copy of currently selected nodes and links between them.
"""
scheme = self.scheme()
if scheme is None:
return [], []
# ensure up to date node properties (settings)
scheme.sync_node_properties()
# original nodes and links
nodes = self.selectedNodes()
links = [link for link in scheme.links
if link.source_node in nodes and
link.sink_node in nodes]
# deepcopied nodes and links
nodedups = [copy_node(node) for node in nodes]
node_to_dup = dict(zip(nodes, nodedups))
linkdups = [copy_link(link, source=node_to_dup[link.source_node],
sink=node_to_dup[link.sink_node])
for link in links]
return nodedups, linkdups
def __pasteFromClipboard(self):
"""Paste a workflow part from system clipboard."""
buff = clipboard_data(MimeTypeWorkflowFragment)
if buff is None:
return
sch = Scheme()
try:
sch.load_from(io.BytesIO(buff), registry=self.__registry, )
except Exception:
log.error("pasteFromClipboard:", exc_info=True)
QApplication.beep()
return
self.__paste(sch.nodes, sch.links, self.__pasteOrigin)
self.__pasteOrigin = self.__pasteOrigin + DuplicateOffset
def __paste(self, nodedups, linkdups, pos: Optional[QPointF] = None,
commandname=None):
"""
Paste nodes and links to canvas. Arguments are expected to be duplicated nodes/links.
"""
scheme = self.scheme()
if scheme is None:
return
# find unique names for new nodes
allnames = {node.title for node in scheme.nodes + nodedups}
for nodedup in nodedups:
nodedup.title = uniquify(
nodedup.title, allnames, pattern="{item} ({_})", start=1)
if pos is not None:
# top left of nodedups brect
origin = nodes_top_left(nodedups)
delta = pos - origin
# move nodedups to be relative to pos
for nodedup in nodedups:
nodedup.position = (
nodedup.position[0] + delta.x(),
nodedup.position[1] + delta.y(),
)
if commandname is None:
commandname = self.tr("Paste")
# create nodes, links
command = QUndoCommand(commandname)
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
# deselect selected
selected = self.scene().selectedItems()
for item in selected:
item.setSelected(False)
# select pasted
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()
[docs] 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
[docs] 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)
def copy_node(node):
# type: (SchemeNode) -> SchemeNode
return SchemeNode(
node.description, node.title, position=node.position,
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))
def clipboard_has_format(mimetype):
# type: (str) -> bool
"""Does the system clipboard contain data for mimetype?"""
cb = QApplication.clipboard()
if cb is None:
return False
mime = cb.mimeData()
if mime is None:
return False
return mime.hasFormat(mimetype)
def clipboard_data(mimetype):
# type: (str) -> Optional[bytes]
"""Return the binary data of the system clipboard for mimetype."""
cb = QApplication.clipboard()
if cb is None:
return None
mime = cb.mimeData()
if mime is None:
return None
if mime.hasFormat(mimetype):
return bytes(mime.data(mimetype))
else:
return None
def nodes_top_left(nodes):
# type: (List[SchemeNode]) -> QPointF
"""Return the top left point of bbox containing all the node positions."""
return QPointF(
min((n.position[0] for n in nodes), default=0),
min((n.position[1] for n in nodes), default=0)
)
@contextmanager
def disable_undo_stack_actions(
undo: QAction, redo: QAction, stack: QUndoStack
) -> Generator[None, None, None]:
"""
Disable the undo/redo actions of an undo stack.
On exit restore the enabled state to match the `stack.canUndo()`
and `stack.canRedo()`.
Parameters
----------
undo: QAction
redo: QAction
stack: QUndoStack
Returns
-------
context: ContextManager
"""
undo.setEnabled(False)
redo.setEnabled(False)
try:
yield
finally:
undo.setEnabled(stack.canUndo())
redo.setEnabled(stack.canRedo())