'
'
{name}'
diff --git a/orangecanvas/document/interactions.py b/orangecanvas/document/interactions.py
index 507c22d4f..5e4a42a60 100644
--- a/orangecanvas/document/interactions.py
+++ b/orangecanvas/document/interactions.py
@@ -13,23 +13,23 @@
"""
import typing
-from typing import Optional, Any, Tuple, List, Set, Iterable, Sequence, Dict
-
import abc
import logging
+from operator import itemgetter
from functools import reduce
-
+from typing import (
+ Optional, Any, Tuple, List, Set, Iterable, Sequence, Dict,Union
+)
from AnyQt.QtWidgets import (
QApplication, QGraphicsRectItem, QGraphicsSceneMouseEvent,
- QGraphicsSceneContextMenuEvent, QWidget, QGraphicsItem,
- QGraphicsSceneDragDropEvent, QMenu, QAction, QWidgetAction, QLabel
+ QWidget, QGraphicsItem, QGraphicsSceneDragDropEvent, QMenu, QAction,
+ QWidgetAction, QLabel
)
-from AnyQt.QtGui import QPen, QBrush, QColor, QFontMetrics, QKeyEvent, QFont
+from AnyQt.QtGui import QPen, QBrush, QColor, QFontMetrics, QFont
from AnyQt.QtCore import (
- Qt, QObject, QCoreApplication, QSizeF, QPointF, QRect, QRectF, QLineF,
+ Qt, QObject, QSizeF, QPointF, QRect, QRectF, QLineF,
QPoint, QMimeData,
)
-from AnyQt.QtCore import pyqtSignal as Signal
from orangecanvas.document.commands import UndoCommand
from .usagestatistics import UsageStatistics
@@ -37,12 +37,12 @@
from ..registry.qt import QtWidgetRegistry, tooltip_helper, whats_this_helper
from .. import scheme
from ..scheme import (
- SchemeNode as Node, SchemeLink as Link, Scheme, WorkflowEvent,
- compatible_channels
+ Node, MetaNode, Link, Scheme, WorkflowEvent, compatible_channels
)
+from ..scheme.link import _classify_connection
from ..canvas import items
from ..canvas.items import controlpoints
-from ..gui.quickhelp import QuickHelpTipEvent
+from ..gui.scene import UserInteraction as _UserInteraction
from . import commands
from .editlinksdialog import EditLinksDialog
from ..utils import unique
@@ -64,7 +64,7 @@ def assert_not_none(optional):
return optional
-class UserInteraction(QObject):
+class UserInteraction(_UserInteraction):
"""
Base class for user interaction handlers.
@@ -77,216 +77,19 @@ class UserInteraction(QObject):
deleteOnEnd : bool, optional
Should the UserInteraction be deleted when it finishes (``True``
by default).
-
"""
- # Cancel reason flags
-
- #: No specified reason
- NoReason = 0
- #: User canceled the operation (e.g. pressing ESC)
- UserCancelReason = 1
- #: Another interaction was set
- InteractionOverrideReason = 3
- #: An internal error occurred
- ErrorReason = 4
- #: Other (unspecified) reason
- OtherReason = 5
-
- #: Emitted when the interaction is set on the scene.
- started = Signal()
-
- #: Emitted when the interaction finishes successfully.
- finished = Signal()
-
- #: Emitted when the interaction ends (canceled or finished)
- ended = Signal()
-
- #: Emitted when the interaction is canceled.
- canceled = Signal([], [int])
-
- def __init__(self, document, parent=None, deleteOnEnd=True):
- # type: ('SchemeEditWidget', Optional[QObject], bool) -> None
- super().__init__(parent)
+ def __init__(self, document, parent=None, deleteOnEnd=True, **kwargs):
+ # type: ('SchemeEditWidget', Optional[QObject], bool, Any) -> None
+ scene = document.currentScene()
+ super().__init__(scene, parent, deleteOnEnd=deleteOnEnd, **kwargs)
self.document = document
- self.scene = document.scene()
scheme_ = document.scheme()
+ root = document.root()
+ assert root is not None
assert scheme_ is not None
self.scheme = scheme_ # type: scheme.Scheme
+ self.root = root
self.suggestions = document.suggestions()
- self.deleteOnEnd = deleteOnEnd
-
- self.cancelOnEsc = False
-
- self.__finished = False
- self.__canceled = False
- self.__cancelReason = self.NoReason
-
- def start(self):
- # type: () -> None
- """
- Start the interaction. This is called by the :class:`CanvasScene` when
- the interaction is installed.
-
- .. note:: Must be called from subclass implementations.
-
- """
- self.started.emit()
-
- def end(self):
- # type: () -> None
- """
- Finish the interaction. Restore any leftover state in this method.
-
- .. note:: This gets called from the default :func:`cancel`
- implementation.
-
- """
- self.__finished = True
-
- if self.scene.user_interaction_handler is self:
- self.scene.set_user_interaction_handler(None)
-
- if self.__canceled:
- self.canceled.emit()
- self.canceled[int].emit(self.__cancelReason)
- else:
- self.finished.emit()
-
- self.ended.emit()
-
- if self.deleteOnEnd:
- self.deleteLater()
-
- def cancel(self, reason=OtherReason):
- # type: (int) -> None
- """
- Cancel the interaction with `reason`.
- """
-
- self.__canceled = True
- self.__cancelReason = reason
-
- self.end()
-
- def isFinished(self):
- # type: () -> bool
- """
- Is the interaction finished.
- """
- return self.__finished
-
- def isCanceled(self):
- # type: () -> bool
- """
- Was the interaction canceled.
- """
- return self.__canceled
-
- def cancelReason(self):
- # type: () -> int
- """
- Return the reason the interaction was canceled.
- """
- return self.__cancelReason
-
- def postQuickTip(self, contents: str) -> None:
- """
- Post a QuickHelpTipEvent with rich text `contents` to the document
- editor.
- """
- hevent = QuickHelpTipEvent("", contents)
- QApplication.postEvent(self.document, hevent)
-
- def clearQuickTip(self):
- """Clear the quick tip help event."""
- self.postQuickTip("")
-
- def mousePressEvent(self, event):
- # type: (QGraphicsSceneMouseEvent) -> bool
- """
- Handle a `QGraphicsScene.mousePressEvent`.
- """
- return False
-
- def mouseMoveEvent(self, event):
- # type: (QGraphicsSceneMouseEvent) -> bool
- """
- Handle a `GraphicsScene.mouseMoveEvent`.
- """
- return False
-
- def mouseReleaseEvent(self, event):
- # type: (QGraphicsSceneMouseEvent) -> bool
- """
- Handle a `QGraphicsScene.mouseReleaseEvent`.
- """
- return False
-
- def mouseDoubleClickEvent(self, event):
- # type: (QGraphicsSceneMouseEvent) -> bool
- """
- Handle a `QGraphicsScene.mouseDoubleClickEvent`.
- """
- return False
-
- def keyPressEvent(self, event):
- # type: (QKeyEvent) -> bool
- """
- Handle a `QGraphicsScene.keyPressEvent`
- """
- if self.cancelOnEsc and event.key() == Qt.Key_Escape:
- self.cancel(self.UserCancelReason)
- return False
-
- def keyReleaseEvent(self, event):
- # type: (QKeyEvent) -> bool
- """
- Handle a `QGraphicsScene.keyPressEvent`
- """
- return False
-
- def contextMenuEvent(self, event):
- # type: (QGraphicsSceneContextMenuEvent) -> bool
- """
- Handle a `QGraphicsScene.contextMenuEvent`
- """
- return False
-
- def dragEnterEvent(self, event):
- # type: (QGraphicsSceneDragDropEvent) -> bool
- """
- Handle a `QGraphicsScene.dragEnterEvent`
-
- .. versionadded:: 0.1.20
- """
- return False
-
- def dragMoveEvent(self, event):
- # type: (QGraphicsSceneDragDropEvent) -> bool
- """
- Handle a `QGraphicsScene.dragMoveEvent`
-
- .. versionadded:: 0.1.20
- """
- return False
-
- def dragLeaveEvent(self, event):
- # type: (QGraphicsSceneDragDropEvent) -> bool
- """
- Handle a `QGraphicsScene.dragLeaveEvent`
-
- .. versionadded:: 0.1.20
- """
- return False
-
- def dropEvent(self, event):
- # type: (QGraphicsSceneDragDropEvent) -> bool
- """
- Handle a `QGraphicsScene.dropEvent`
-
- .. versionadded:: 0.1.20
- """
- return False
class NoPossibleLinksError(ValueError):
@@ -306,6 +109,60 @@ def wrapped(*args):
return wrapped
+def propose_links(
+ workflow: Scheme,
+ source_node: 'Node',
+ sink_node: 'Node',
+ source_signal: Optional[OutputSignal] = None,
+ sink_signal: Optional[InputSignal] = None
+) -> List[Tuple[OutputSignal, InputSignal, int]]:
+ """
+ Return a list of ordered (:class:`OutputSignal`,
+ :class:`InputSignal`, weight) tuples that could be added to
+ the `workflow` between `source_node` and `sink_node`.
+
+ .. note:: This can depend on the links already in the `workflow`.
+ """
+ if source_node is sink_node and \
+ not workflow.loop_flags() & Scheme.AllowSelfLoops:
+ # Self loops are not enabled
+ return []
+ elif not workflow.loop_flags() & Scheme.AllowLoops and \
+ workflow.is_ancestor(sink_node, source_node):
+ # Loops are not enabled.
+ return []
+
+ outputs = [source_signal] if source_signal \
+ else source_node.output_channels()
+ inputs = [sink_signal] if sink_signal \
+ else sink_node.input_channels()
+
+ # Get existing links to sink channels that are Single.
+ links = workflow.find_links(None, None, sink_node)
+ already_connected_sinks = [link.sink_channel for link in links
+ if link.sink_channel.single]
+
+ def weight(out_c, in_c):
+ # type: (OutputSignal, InputSignal) -> int
+ if out_c.explicit or in_c.explicit:
+ weight = -1 # Negative weight for explicit links
+ else:
+ check = [in_c not in already_connected_sinks,
+ bool(in_c.default),
+ bool(out_c.default)]
+ weights = [2 ** i for i in range(len(check), 0, -1)]
+ weight = sum([w for w, c in zip(weights, check) if c])
+ return weight
+
+ proposed_links = []
+ for out_c in outputs:
+ for in_c in inputs:
+ if compatible_channels(out_c, in_c):
+ proposed_links.append((out_c, in_c, weight(out_c, in_c)))
+
+ return sorted(proposed_links, key=itemgetter(-1), reverse=True)
+
+
class NewLinkAction(UserInteraction):
"""
User drags a new link from an existing `NodeAnchorItem` to create
@@ -399,11 +256,11 @@ def __possible_connection_signal_pairs(
node2 = self.scene.node_for_item(target_item)
if self.direction == self.FROM_SOURCE:
- links = self.scheme.propose_links(node1, node2,
- source_signal=self.from_signal)
+ links = propose_links(self.scheme, node1, node2,
+ source_signal=self.from_signal)
else:
- links = self.scheme.propose_links(node2, node1,
- sink_signal=self.from_signal)
+ links = propose_links(self.scheme, node2, node1,
+ sink_signal=self.from_signal)
return [(s1, s2) for s1, s2, _ in links]
def can_connect(self, target_item):
@@ -612,7 +469,7 @@ def mouseReleaseEvent(self, event):
node = None
if node is not None:
- commands.AddNodeCommand(self.scheme, node,
+ commands.AddNodeCommand(self.scheme, node, self.root,
parent=self.macro)
if node is not None and not self.showing_incompatible_widget:
@@ -662,27 +519,33 @@ def create_new(self, event):
menu = self.document.quickMenu()
node = self.scene.node_for_item(self.from_item)
from_signal = self.from_signal
- from_desc = node.description
+ from_sink = self.direction == self.FROM_SINK
+ if self.from_signal:
+ from_signals = [self.from_signal]
+ elif from_sink:
+ from_signals = node.input_channels()
+ else:
+ from_signals = node.output_channels()
+ from_desc = getattr(node, "description", None)
+ if from_desc is not None:
+ from_name = from_desc.name
+ else:
+ from_name = ""
def is_compatible(
- source_signal: OutputSignal,
- source: WidgetDescription,
- sink: WidgetDescription,
- sink_signal: InputSignal
+ source_signals: Sequence[OutputSignal],
+ sink_signals: Sequence[InputSignal],
) -> bool:
+
return any(scheme.compatible_channels(output, input)
- for output
- in ([source_signal] if source_signal else source.outputs)
- for input
- in ([sink_signal] if sink_signal else sink.inputs))
+ for output in source_signals for input in sink_signals)
- from_sink = self.direction == self.FROM_SINK
if from_sink:
# Reverse the argument order.
is_compatible = reversed_arguments(is_compatible)
- suggestion_sort = self.suggestions.get_source_suggestions(from_desc.name)
+ suggestion_sort = self.suggestions.get_source_suggestions(from_name)
else:
- suggestion_sort = self.suggestions.get_sink_suggestions(from_desc.name)
+ suggestion_sort = self.suggestions.get_sink_suggestions(from_name)
def sort(left, right):
# list stores frequencies, so sign is flipped
@@ -693,7 +556,7 @@ def sort(left, right):
def filter(index):
desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
if isinstance(desc, WidgetDescription):
- return is_compatible(from_signal, from_desc, desc, None)
+ return is_compatible(from_signals, desc.outputs if from_sink else desc.inputs)
else:
return False
@@ -732,11 +595,13 @@ def connect_nodes(
detailed dialog for link editing.
"""
+ root = self.root
UsageStatistics.set_sink_anchor_open(sink_signal is not None)
UsageStatistics.set_source_anchor_open(source_signal is not None)
try:
- possible = self.scheme.propose_links(source_node, sink_node,
- source_signal, sink_signal)
+ possible = propose_links(
+ self.scheme, source_node, sink_node, source_signal, sink_signal
+ )
log.debug("proposed (weighted) links: %r",
[(s1.name, s2.name, w) for s1, s2, w in possible])
@@ -746,8 +611,6 @@ def connect_nodes(
source, sink, w = possible[0]
- # just a list of signal tuples for now, will be converted
- # to SchemeLinks later
links_to_add = [] # type: List[Link]
links_to_remove = [] # type: List[Link]
show_link_dialog = False
@@ -788,9 +651,9 @@ def connect_nodes(
if rstatus == EditLinksDialog.Rejected:
raise UserCanceledError
else:
- # links_to_add now needs to be a list of actual SchemeLinks
+ # links_to_add now needs to be a list of actual Links
links_to_add = [
- scheme.SchemeLink(source_node, source, sink_node, sink)
+ Link(source_node, source, sink_node, sink)
]
links_to_add, links_to_remove = \
add_links_plan(self.scheme, links_to_add)
@@ -799,7 +662,7 @@ def connect_nodes(
self.cleanup()
for link in links_to_remove:
- commands.RemoveLinkCommand(self.scheme, link,
+ commands.RemoveLinkCommand(self.scheme, link, root,
parent=self.macro)
for link in links_to_add:
@@ -811,7 +674,7 @@ def connect_nodes(
)
if not duplicate:
- commands.AddLinkCommand(self.scheme, link,
+ commands.AddLinkCommand(self.scheme, link, root,
parent=self.macro)
except scheme.IncompatibleChannelTypeError:
@@ -853,7 +716,7 @@ def edit_links(
if status == EditLinksDialog.Accepted:
links_to_add = [
- scheme.SchemeLink(
+ Link(
source_node, source_channel,
sink_node, sink_channel
) for source_channel, sink_channel in links_to_add_spec
@@ -954,7 +817,7 @@ def edit_links(
dlg = EditLinksDialog(parent, windowTitle="Edit Links")
- # all SchemeLinks between the two nodes.
+ # all Links between the two nodes.
links = scheme.find_links(source_node=source_node, sink_node=sink_node)
existing_links = [(link.source_channel, link.sink_channel)
for link in links]
@@ -1003,12 +866,14 @@ def conflicting_single_link(scheme, link):
If no such channel exists (or sink channel is not 'single')
return `None`.
"""
+ node = link.sink_node
+ if isinstance(node, MetaNode):
+ node = node.node_for_input_channel(link.sink_channel)
if link.sink_channel.single:
existing = scheme.find_links(
- sink_node=link.sink_node,
+ sink_node=node,
sink_channel=link.sink_channel
)
-
if existing:
assert len(existing) == 1
return existing[0]
@@ -1019,8 +884,13 @@ def remove_duplicates(links_to_add, links_to_remove):
# type: (List[Link], List[Link]) -> Tuple[List[Link], List[Link]]
def link_key(link):
# type: (Link) -> Tuple[Node, OutputSignal, Node, InputSignal]
- return (link.source_node, link.source_channel,
- link.sink_node, link.sink_channel)
+ source_node, source_channel = link.source_node, link.source_channel
+ sink_node, sink_channel = link.sink_node, link.sink_channel
+ if isinstance(source_node, MetaNode):
+ source_node = source_node.node_for_output_channel(source_channel)
+ if isinstance(sink_node, MetaNode):
+ sink_node = sink_node.node_for_input_channel(sink_channel)
+ return source_node, source_channel, sink_node, sink_channel
add_keys = list(map(link_key, links_to_add))
remove_keys = list(map(link_key, links_to_remove))
@@ -1234,16 +1104,16 @@ def _bound_selection_rect(self, rect):
class EditNodeLinksAction(UserInteraction):
"""
- Edit multiple links between two :class:`SchemeNode` instances using
+ Edit multiple links between two :class:`Node` instances using
a :class:`EditLinksDialog`
Parameters
----------
document : :class:`SchemeEditWidget`
The editor widget.
- source_node : :class:`SchemeNode`
+ source_node : :class:`Node`
The source (link start) node for the link editor.
- sink_node : :class:`SchemeNode`
+ sink_node : :class:`Node`
The sink (link end) node for the link editor.
"""
@@ -1316,8 +1186,8 @@ def edit_links(self, initial_links=None):
self.document.removeLink(links[0])
for source_channel, sink_channel in links_to_add:
- link = scheme.SchemeLink(self.source_node, source_channel,
- self.sink_node, sink_channel)
+ link = Link(self.source_node, source_channel,
+ self.sink_node, sink_channel)
self.document.addLink(link)
diff --git a/orangecanvas/document/schemeedit.py b/orangecanvas/document/schemeedit.py
index 57f3d540f..5368256c9 100644
--- a/orangecanvas/document/schemeedit.py
+++ b/orangecanvas/document/schemeedit.py
@@ -11,24 +11,23 @@
import itertools
import re
import sys
-import unicodedata
import copy
+import warnings
import dictdiffer
from operator import attrgetter
from urllib.parse import urlencode
-from contextlib import ExitStack, contextmanager
+from contextlib import ExitStack
from typing import (
- List, Tuple, Optional, Container, Dict, Any, Iterable, Generator, Sequence
+ List, Tuple, Optional, Dict, Any, Iterable, Sequence, cast
)
from AnyQt.QtWidgets import (
QWidget, QVBoxLayout, QMenu, QAction, QActionGroup,
QUndoStack, QGraphicsItem, QGraphicsTextItem,
- QFormLayout, QComboBox, QDialog, QDialogButtonBox, QMessageBox, QCheckBox,
QGraphicsSceneDragDropEvent, QGraphicsSceneMouseEvent,
QGraphicsSceneContextMenuEvent, QGraphicsView, QGraphicsScene,
- QApplication
+ QApplication, QGestureEvent, QSwipeGesture
)
from AnyQt.QtGui import (
QKeySequence, QCursor, QFont, QPainter, QPixmap, QColor, QIcon,
@@ -40,7 +39,13 @@
from AnyQt.QtCore import pyqtProperty as Property, pyqtSignal as Signal
from orangecanvas.document.commands import UndoCommand
-from .interactions import DropHandler
+from .interactions import DropHandler, UserInteraction, propose_links
+from .utils import (
+ prepare_macro_patch, disable_undo_stack_actions, nodes_bounding_box,
+ prepare_expand_macro
+)
+from .windowgroupsdialog import SaveWindowGroup
+from ..gui.breadcrumbs import Breadcrumbs
from ..registry import WidgetDescription, WidgetRegistry
from .suggestions import Suggestions
from .usagestatistics import UsageStatistics
@@ -50,18 +55,19 @@
message_information, disabled, clipboard_has_format, clipboard_data
)
from ..scheme import (
- scheme, signalmanager, Scheme, SchemeNode, SchemeLink,
- BaseSchemeAnnotation, SchemeTextAnnotation, WorkflowEvent
+ scheme, signalmanager, Scheme, SchemeNode, MetaNode, Node, Link,
+ Annotation, TextAnnotation, WorkflowEvent,
)
+from ..scheme.element import Element
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 ..canvas.items.annotationitem import AnnotationItem
from . import interactions
from . import commands
from . import quickmenu
-from ..utils import findf, UNUSED
+from ..utils import findf, UNUSED, apply_all, uniquify, is_printable
from ..utils.qinvoke import connect_with_context
Pos = Tuple[float, float]
@@ -159,13 +165,13 @@ class OpenAnchors(enum.Enum):
#: Channel anchors separate on hover on Shift key
OnShift = "OnShift"
- def __init__(self, parent=None, ):
- super().__init__(parent)
-
+ def __init__(self, parent=None, **kwargs):
+ super().__init__(parent, **kwargs)
+ self.grabGesture(Qt.SwipeGesture)
self.__modified = False
self.__registry = None # type: Optional[WidgetRegistry]
self.__scheme = None # type: Optional[Scheme]
-
+ self.__root = None # type: Optional[MetaNode]
self.__widgetManager = None # type: Optional[WidgetManager]
self.__path = ""
@@ -178,7 +184,7 @@ def __init__(self, parent=None, ):
self.__possibleSelectionHandler = None
self.__possibleMouseItemsMove = False
self.__itemsMoving = {}
- self.__contextMenuTarget = None # type: Optional[SchemeLink]
+ self.__contextMenuTarget = None # type: Optional[Link]
self.__dropTarget = None # type: Optional[items.LinkItem]
self.__quickMenu = None # type: Optional[quickmenu.QuickMenu]
self.__quickTip = ""
@@ -203,6 +209,8 @@ def __init__(self, parent=None, ):
self.__cleanAnnotations = []
self.__dropHandlers = () # type: Sequence[DropHandler]
+ self.__history = [] # type: List[MetaNode]
+ self.__historyIndex = -1
self.__editFinishedMapper = QSignalMapper(self)
self.__editFinishedMapper.mappedObject.connect(
@@ -224,6 +232,8 @@ def __init__(self, parent=None, ):
self.__editMenu.addAction(self.__copySelectedAction)
self.__editMenu.addAction(self.__pasteAction)
self.__editMenu.addAction(self.__selectAllAction)
+ self.__editMenu.addAction(self.__createMacroAction)
+ self.__editMenu.addAction(self.__expandMacroAction)
# Widget context menu
self.__widgetMenu = QMenu(self.tr("Widget"), self)
@@ -233,12 +243,15 @@ def __init__(self, parent=None, ):
self.__widgetMenu.addAction(self.__removeSelectedAction)
self.__widgetMenu.addAction(self.__duplicateSelectedAction)
self.__widgetMenu.addAction(self.__copySelectedAction)
+ self.__widgetMenu.addAction(self.__createMacroAction)
+ self.__widgetMenu.addAction(self.__expandMacroAction)
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.addAction(self.__openParentMetaNodeAction)
self.__menuBarWidgetMenu.addSeparator()
self.__menuBarWidgetMenu.addAction(self.__renameAction)
self.__menuBarWidgetMenu.addAction(self.__removeSelectedAction)
@@ -431,7 +444,19 @@ def color_icon(color):
shortcut=QKeySequence("Ctrl+C"),
triggered=self.__copyToClipboard,
)
-
+ self.__createMacroAction = QAction(
+ self.tr("Create Macro"), self,
+ objectName="create-macro-action",
+ enabled=False,
+ shortcut=QKeySequence(Qt.ControlModifier | Qt.ShiftModifier | Qt.Key_M),
+ triggered=self.createMacroFromSelection,
+ )
+ self.__expandMacroAction = QAction(
+ self.tr("Expand Macro"), self,
+ objectName="expand-macro-action",
+ enabled=False,
+ triggered=self.__expandMacroFromSelection,
+ )
self.__pasteAction = QAction(
self.tr("Paste"), self,
objectName="paste-action",
@@ -452,6 +477,7 @@ def color_icon(color):
self.__linkResetAction,
self.__duplicateSelectedAction,
self.__copySelectedAction,
+ self.__createMacroAction,
self.__pasteAction
])
@@ -512,12 +538,27 @@ def color_icon(color):
)
self.__raiseWidgetsAction.triggered.connect(self.__raiseToFont)
self.addAction(self.__raiseWidgetsAction)
+ self.__openParentMetaNodeAction = QAction(
+ self.tr("Up"), self,
+ objectName="open-parent-meta-node-action",
+ shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_Up),
+ shortcutContext=Qt.WindowShortcut,
+ enabled=False,
+ )
+ self.__openParentMetaNodeAction.triggered.connect(self.openParentMetaNode)
+ self.addAction(self.__openParentMetaNodeAction)
def __setupUi(self):
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
-
+ self.__nav_bar = Breadcrumbs(
+ visible=False,
+ objectName="breadcrumbs-navigation-bar"
+ )
+ self.__nav_bar.setBackgroundRole(QPalette.Base)
+ self.__nav_bar.setAutoFillBackground(True)
+ self.__nav_bar.activated.connect(self.__on_nav_bar_activated)
scene = CanvasScene(self)
scene.setItemIndexMethod(CanvasScene.NoIndex)
self.__setupScene(scene)
@@ -527,8 +568,8 @@ def __setupUi(self):
view.setRenderHint(QPainter.Antialiasing)
self.__view = view
- self.__scene = scene
-
+ self.__scenes = {"root": scene}
+ layout.addWidget(self.__nav_bar)
layout.addWidget(view)
self.setLayout(layout)
@@ -550,9 +591,6 @@ def __setupScene(self, scene):
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.link_item_activated.connect(self.__onLinkActivate)
@@ -560,7 +598,6 @@ def __setupScene(self, scene):
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
@@ -571,17 +608,11 @@ def __teardownScene(self, scene):
# 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()
@@ -639,8 +670,8 @@ def setModified(self, modified):
if not modified:
if self.__scheme:
self.__cleanProperties = node_properties(self.__scheme)
- self.__cleanLinks = self.__scheme.links
- self.__cleanAnnotations = self.__scheme.annotations
+ self.__cleanLinks = self.__scheme.all_links()
+ self.__cleanAnnotations = self.__scheme.all_annotations()
else:
self.__cleanProperties = {}
self.__cleanLinks = []
@@ -687,7 +718,7 @@ def uncleanProperties(self):
cleanProperties = self.__cleanProperties
# ignore diff for deleted nodes
- currentNodes = self.__scheme.nodes
+ currentNodes = self.__scheme.all_nodes()
cleanCurrentNodeProperties = {k: v
for k, v in cleanProperties.items()
if k in currentNodes}
@@ -704,7 +735,7 @@ def uncleanProperties(self):
def restoreProperties(self, dict_diff):
ref_properties = {
- node: node.properties for node in self.__scheme.nodes
+ node: node.properties for node in self.__scheme.all_nodes()
}
dictdiffer.patch(dict_diff, ref_properties, in_place=True)
@@ -750,7 +781,10 @@ def setChannelNamesVisible(self, visible):
"""
if self.__channelNamesVisible != visible:
self.__channelNamesVisible = visible
- self.__scene.set_channel_names_visible(visible)
+ apply_all(
+ lambda s: s.set_channel_names_visible(visible),
+ self.__scenes.values(),
+ )
def channelNamesVisible(self):
# type: () -> bool
@@ -766,7 +800,10 @@ def setNodeAnimationEnabled(self, enabled):
"""
if self.__nodeAnimationEnabled != enabled:
self.__nodeAnimationEnabled = enabled
- self.__scene.set_node_animation_enabled(enabled)
+ apply_all(
+ lambda s: s.set_node_animation_enabled(enabled),
+ self.__scenes.values(),
+ )
def nodeAnimationEnabled(self):
# type () -> bool
@@ -777,8 +814,11 @@ def nodeAnimationEnabled(self):
def setOpenAnchorsMode(self, state: OpenAnchors):
self.__openAnchorsMode = state
- self.__scene.set_widget_anchors_open(
- state == SchemeEditWidget.OpenAnchors.Always
+ apply_all(
+ lambda s: s.set_widget_anchors_open(
+ state == SchemeEditWidget.OpenAnchors.Always
+ ),
+ self.__scenes.values(),
)
def openAnchorsMode(self) -> OpenAnchors:
@@ -836,6 +876,7 @@ def setScheme(self, scheme):
self.__statistics.write_statistics()
self.__scheme = scheme
+ self.__root = scheme.root()
self.__suggestions.set_scheme(self)
self.setPath("")
@@ -847,8 +888,8 @@ def setScheme(self, scheme):
self.__reset_window_group_menu
)
self.__cleanProperties = node_properties(scheme)
- self.__cleanLinks = scheme.links
- self.__cleanAnnotations = scheme.annotations
+ self.__cleanLinks = scheme.all_links()
+ self.__cleanAnnotations = scheme.all_annotations()
sm = scheme.findChild(signalmanager.SignalManager)
if sm:
sm.stateChanged.connect(self.__signalManagerStateChanged)
@@ -864,37 +905,48 @@ def setScheme(self, scheme):
self.__cleanLinks = []
self.__cleanAnnotations = []
- self.__teardownScene(self.__scene)
- self.__scene.deleteLater()
+ # clear all scenes
+ for _, scene in self.__scenes.items():
+ self.__teardownScene(scene)
+ scene.deleteLater()
+
+ self.__scenes.clear()
+ self.__annotationGeomChanged.deleteLater()
+ self.__annotationGeomChanged = QSignalMapper(self)
self.__undoStack.clear()
- self.__scene = CanvasScene(self)
- self.__scene.setItemIndexMethod(CanvasScene.NoIndex)
- self.__setupScene(self.__scene)
+ scene = CanvasScene(self)
+ scene.setItemIndexMethod(CanvasScene.NoIndex)
+ self.__setupScene(scene)
+ self.__scenes[self.__root] = scene
- self.__scene.set_scheme(scheme)
- self.__view.setScene(self.__scene)
+ scene.set_scheme(scheme, self.__root)
+ self.__view.setScene(scene)
+ self.__history = [self.__root]
+ self.__historyIndex = 0
if self.__scheme:
self.__scheme.installEventFilter(self)
- nodes = self.__scheme.nodes
+ nodes = self.__root.nodes()
if nodes:
+ # TODO: First in root layer
self.ensureVisible(nodes[0])
self.__reset_window_group_menu()
def ensureVisible(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
"""
Scroll the contents of the viewport so that `node` is visible.
Parameters
----------
- node: SchemeNode
+ node: Node
"""
if self.__scheme is None:
return
- item = self.__scene.item_for_node(node)
+ scene = self.currentScene()
+ item = scene.item_for_node(node)
self.__view.ensureVisible(item)
def scheme(self):
@@ -904,13 +956,22 @@ def scheme(self):
"""
return self.__scheme
+ def root(self) -> Optional[MetaNode]:
+ return self.__root
+
def scene(self):
# type: () -> QGraphicsScene
"""
Return the :class:`QGraphicsScene` instance used to display the
current scheme.
"""
- return self.__scene
+ warnings.warn(
+ "scene is deprecated", DeprecationWarning, stacklevel=2
+ )
+ return self.__scenes.get(self.__scheme.root())
+
+ def currentScene(self) -> CanvasScene:
+ return self.__view.scene()
def view(self):
# type: () -> QGraphicsView
@@ -938,9 +999,7 @@ def setRegistry(self, registry):
# 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
+ self.__quickMenu = None
def registry(self):
return self.__registry
@@ -977,13 +1036,13 @@ def setDescription(self, description):
)
def addNode(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
"""
- Add a new node (:class:`.SchemeNode`) to the document.
+ Add a new node (:class:`.Node`) to the document.
"""
if self.__scheme is None:
raise NoWorkflowError()
- command = commands.AddNodeCommand(self.__scheme, node)
+ command = commands.AddNodeCommand(self.__scheme, node, self.__root)
self.__undoStack.push(command)
def createNewNode(self, description, title=None, position=None):
@@ -1021,14 +1080,10 @@ def enumerateTitle(self, title):
"""
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)
+ curr_titles = set([node.title for node in self.__scheme.all_nodes()])
+ if title not in curr_titles:
+ return title
+ return uniquify(title, curr_titles, "{item} ({_})", start=1)
def nextPosition(self):
# type: () -> Tuple[float, float]
@@ -1036,8 +1091,8 @@ def nextPosition(self):
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
+ if self.__root is not None:
+ nodes = self.__root.nodes()
else:
nodes = []
if nodes:
@@ -1048,19 +1103,19 @@ def nextPosition(self):
return position
def removeNode(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
"""
- Remove a `node` (:class:`.SchemeNode`) from the scheme
+ Remove a `node` (:class:`.Node`) from the scheme
"""
if self.__scheme is None:
raise NoWorkflowError()
- command = commands.RemoveNodeCommand(self.__scheme, node)
+ command = commands.RemoveNodeCommand(self.__scheme, node, self.__root)
self.__undoStack.push(command)
def renameNode(self, node, title):
- # type: (SchemeNode, str) -> None
+ # type: (Node, str) -> None
"""
- Rename a `node` (:class:`.SchemeNode`) to `title`.
+ Rename a `node` (:class:`.Node`) to `title`.
"""
if self.__scheme is None:
raise NoWorkflowError()
@@ -1069,27 +1124,27 @@ def renameNode(self, node, title):
)
def addLink(self, link):
- # type: (SchemeLink) -> None
+ # type: (Link) -> None
"""
- Add a `link` (:class:`.SchemeLink`) to the scheme.
+ Add a `link` (:class:`.Link`) to the scheme.
"""
if self.__scheme is None:
raise NoWorkflowError()
- command = commands.AddLinkCommand(self.__scheme, link)
+ command = commands.AddLinkCommand(self.__scheme, link, self.__root)
self.__undoStack.push(command)
def removeLink(self, link):
- # type: (SchemeLink) -> None
+ # type: (Link) -> None
"""
- Remove a link (:class:`.SchemeLink`) from the scheme.
+ Remove a link (:class:`.Link`) from the scheme.
"""
if self.__scheme is None:
raise NoWorkflowError()
- command = commands.RemoveLinkCommand(self.__scheme, link)
+ command = commands.RemoveLinkCommand(self.__scheme, link, self.__root)
self.__undoStack.push(command)
def insertNode(self, new_node, old_link):
- # type: (SchemeNode, SchemeLink) -> None
+ # type: (Node, Link) -> None
"""
Insert a node in-between two linked nodes.
"""
@@ -1100,8 +1155,8 @@ def insertNode(self, new_node, old_link):
source_channel = old_link.source_channel
sink_channel = old_link.sink_channel
- proposed_links = (self.__scheme.propose_links(source_node, new_node),
- self.__scheme.propose_links(new_node, sink_node))
+ proposed_links = (propose_links(self.__scheme, source_node, new_node),
+ propose_links(self.__scheme, new_node, sink_node))
# Preserve existing {source,sink}_channel if possible; use first
# proposed if not.
first = findf(proposed_links[0], lambda t: t[0] == source_channel,
@@ -1109,10 +1164,10 @@ def insertNode(self, new_node, old_link):
second = findf(proposed_links[1], lambda t: t[1] == sink_channel,
default=proposed_links[1][0])
new_links = (
- SchemeLink(source_node, first[0], new_node, first[1]),
- SchemeLink(new_node, second[0], sink_node, second[1])
+ Link(source_node, first[0], new_node, first[1]),
+ Link(new_node, second[0], sink_node, second[1])
)
- command = commands.InsertNodeCommand(self.__scheme, new_node, old_link, new_links)
+ command = commands.InsertNodeCommand(self.__scheme, new_node, old_link, new_links, self.__root)
self.__undoStack.push(command)
def onNewLink(self, func):
@@ -1122,23 +1177,23 @@ def onNewLink(self, func):
self.__scheme.link_added.connect(func)
def addAnnotation(self, annotation):
- # type: (BaseSchemeAnnotation) -> None
+ # type: (Annotation) -> None
"""
- Add `annotation` (:class:`.BaseSchemeAnnotation`) to the scheme
+ Add `annotation` (:class:`.Annotation`) to the scheme
"""
if self.__scheme is None:
raise NoWorkflowError()
- command = commands.AddAnnotationCommand(self.__scheme, annotation)
+ command = commands.AddAnnotationCommand(self.__scheme, annotation, self.__root)
self.__undoStack.push(command)
def removeAnnotation(self, annotation):
- # type: (BaseSchemeAnnotation) -> None
+ # type: (Annotation) -> None
"""
- Remove `annotation` (:class:`.BaseSchemeAnnotation`) from the scheme.
+ Remove `annotation` (:class:`.Annotation`) from the scheme.
"""
if self.__scheme is None:
raise NoWorkflowError()
- command = commands.RemoveAnnotationCommand(self.__scheme, annotation)
+ command = commands.RemoveAnnotationCommand(self.__scheme, annotation, self.__root)
self.__undoStack.push(command)
def removeSelected(self):
@@ -1146,10 +1201,10 @@ def removeSelected(self):
"""
Remove all selected items in the scheme.
"""
- selected = self.scene().selectedItems()
+ selected = self.currentScene().selectedItems()
if not selected:
return
- scene = self.scene()
+ scene = self.currentScene()
self.__undoStack.beginMacro(self.tr("Remove"))
# order LinkItem removes before NodeItems; Removing NodeItems also
# removes links so some links in selected could already be removed by
@@ -1161,7 +1216,7 @@ def removeSelected(self):
if isinstance(item, items.NodeItem):
node = scene.node_for_item(item)
self.__undoStack.push(
- commands.RemoveNodeCommand(self.__scheme, node)
+ commands.RemoveNodeCommand(self.__scheme, node, self.__root)
)
elif isinstance(item, items.annotationitem.Annotation):
if item.hasFocus() or item.isAncestorOf(scene.focusItem()):
@@ -1169,12 +1224,12 @@ def removeSelected(self):
scene.focusItem().clearFocus()
annot = scene.annotation_for_item(item)
self.__undoStack.push(
- commands.RemoveAnnotationCommand(self.__scheme, annot)
+ commands.RemoveAnnotationCommand(self.__scheme, annot, self.__root)
)
elif isinstance(item, items.LinkItem):
link = scene.link_for_item(item)
self.__undoStack.push(
- commands.RemoveLinkCommand(self.__scheme, link)
+ commands.RemoveLinkCommand(self.__scheme, link, self.__root)
)
self.__undoStack.endMacro()
@@ -1183,10 +1238,16 @@ def selectAll(self):
"""
Select all selectable items in the scheme.
"""
- for item in self.__scene.items():
+ scene = self.currentScene()
+ for item in scene.items():
if item.flags() & QGraphicsItem.ItemIsSelectable:
item.setSelected(True)
+ def clearSelection(self) -> None:
+ """Clear selection."""
+ scene = self.currentScene()
+ scene.clearSelection()
+
def alignToGrid(self):
# type: () -> None
"""
@@ -1195,10 +1256,10 @@ def alignToGrid(self):
# TODO: The the current layout implementation is BAD (fix is urgent).
if self.__scheme is None:
return
-
+ scene = self.currentScene()
tile_size = 150
- tiles = {} # type: Dict[Tuple[int, int], SchemeNode]
- nodes = sorted(self.__scheme.nodes, key=attrgetter("position"))
+ tiles = {} # type: Dict[Tuple[int, int], Node]
+ nodes = sorted(self.__root.nodes(), key=attrgetter("position"))
if nodes:
self.__undoStack.beginMacro(self.tr("Align To Grid"))
@@ -1216,21 +1277,22 @@ def alignToGrid(self):
)
tiles[x, y] = node
- self.__scene.item_for_node(node).setPos(x, y)
+ scene.item_for_node(node).setPos(x, y)
self.__undoStack.endMacro()
def focusNode(self):
- # type: () -> Optional[SchemeNode]
+ # type: () -> Optional[Node]
"""
- Return the current focused :class:`.SchemeNode` or ``None`` if no
+ Return the current focused :class:`.Node` or ``None`` if no
node has focus.
"""
- focus = self.__scene.focusItem()
+ scene = self.currentScene()
+ focus = scene.focusItem()
node = None
if isinstance(focus, items.NodeItem):
try:
- node = self.__scene.node_for_item(focus)
+ node = scene.node_for_item(focus)
except KeyError:
# in case the node has been removed but the scene was not
# yet fully updated.
@@ -1238,43 +1300,141 @@ def focusNode(self):
return node
def selectedNodes(self):
- # type: () -> List[SchemeNode]
+ # type: () -> List[Node]
"""
- Return all selected :class:`.SchemeNode` items.
+ Return all selected :class:`.Node` items.
"""
- return list(map(self.scene().node_for_item,
- self.scene().selected_node_items()))
+ return list(map(self.currentScene().node_for_item,
+ self.currentScene().selected_node_items()))
def selectedLinks(self):
- # type: () -> List[SchemeLink]
- return list(map(self.scene().link_for_item,
- self.scene().selected_link_items()))
+ # type: () -> List[Link]
+ return list(map(self.currentScene().link_for_item,
+ self.currentScene().selected_link_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()))
+ # type: () -> List[Annotation]
+ """
+ Return all selected :class:`.Annotation` items.
+ """
+ return list(map(self.currentScene().annotation_for_item,
+ self.currentScene().selected_annotation_items()))
+
+ def select(self, elements: Sequence[Element]):
+ scene = self.currentScene()
+ items = []
+ for el in elements:
+ if isinstance(el, Node):
+ item = scene.item_for_node(el)
+ elif isinstance(el, Link):
+ item = scene.item_for_link(el)
+ elif isinstance(el, Annotation):
+ item = scene.item_for_annotation(el)
+ else:
+ raise TypeError
+ items.append(item)
+ # scene.clearSelection()
+ apply_all(lambda item: item.setSelected(True), items)
+
+ def setSelection(self, elements: Sequence[Element]):
+ scene = self.currentScene()
+ scene.clearSelection()
+ self.select(elements)
+
+ def __openNodes(self, nodes: Sequence[Node]):
+ if len(nodes) == 1:
+ node = nodes[0]
+ if isinstance(node, MetaNode):
+ self.__historyPush(node)
+ return
+ # TODO: Dispatch to WidgetManager directly
+ for node in nodes:
+ QCoreApplication.sendEvent(
+ node, WorkflowEvent(WorkflowEvent.NodeActivateRequest))
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))
+ self.__openNodes(self.selectedNodes())
+
+ def openParentMetaNode(self):
+ current = self.root()
+ if current is None:
+ return
+ parent = current.parent_node()
+ if parent is None:
+ return
+ self.__historyPush(parent)
+
+ def openMetaNode(self, node: MetaNode):
+ view = self.__view
+ scene = self.__scenes.get(node, None)
+ workflow = self.__scheme
+ handler = self._userInteractionHandler()
+ if handler is not None:
+ handler.cancel()
+ # This is too much state
+ self.__possibleSelectionHandler = None
+ self.__possibleMouseItemsMove = False
+ self.__itemsMoving = {}
+ self.__root = node
+ if scene is None:
+ scene = CanvasScene(self)
+ scene.setItemIndexMethod(CanvasScene.NoIndex)
+ self.__setupScene(scene)
+ scene.set_scheme(workflow, root=node)
+ self.__scenes[node] = scene
+
+ view.setScene(scene)
+ nodes = node.nodes()
+ if nodes:
+ self.ensureVisible(nodes[0])
+ else:
+ view.setScene(scene)
+ self.__openParentMetaNodeAction.setEnabled(node is not workflow.root())
+ components = []
+ node_ = node
+ while node_ is not None:
+ components.append(node_.title)
+ node_ = node_.parent_node()
+
+ self.__nav_bar.setBreadcrumbs(components[::-1])
+ self.__nav_bar.setVisible(node is not workflow.root())
+
+ def __on_nav_bar_activated(self, index):
+ node = self.__root
+ nodes = []
+ while node is not None:
+ nodes.append(node)
+ node = node.parent_node()
+ nodes = nodes[::-1]
+ if nodes[index] is not self.__root:
+ self.__historyPush(nodes[index])
+
+ def __historyPush(self, node: MetaNode):
+ self.openMetaNode(node)
+ if self.__historyIndex >= 0:
+ del self.__history[self.__historyIndex + 1:]
+ else:
+ del self.__history[:]
+ self.__history.append(node)
+ self.__historyIndex = len(self.__history) - 1
+
+ def __historyStep(self, step: int):
+ index = max(self.__historyIndex + step, -1)
+ if 0 <= index < len(self.__history):
+ self.__historyIndex = index
+ self.openMetaNode(self.__history[index])
def editNodeTitle(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
"""
Edit (rename) the `node`'s title.
"""
self.__view.setFocus(Qt.OtherFocusReason)
- scene = self.__scene
+ scene = self.currentScene()
item = scene.item_for_node(node)
item.editTitle()
@@ -1305,11 +1465,13 @@ def setDropHandlers(self, dropHandlers: Sequence[DropHandler]) -> None:
def changeEvent(self, event):
# type: (QEvent) -> None
if event.type() == QEvent.FontChange:
- self.__updateFont()
+ font = self.font()
+ apply_all(lambda s: s.setFont(font), self.__scenes.values())
elif event.type() == QEvent.PaletteChange:
- if self.__scene is not None:
- self.__scene.setPalette(self.palette())
-
+ palette = self.palette()
+ apply_all(
+ lambda s: s.setPalette(palette), self.__scenes.values(),
+ )
super().changeEvent(event)
def __lookup_registry(self, qname: str) -> Optional[WidgetDescription]:
@@ -1339,7 +1501,8 @@ def __desc_from_mime_data(self, data: QMimeData) -> Optional[WidgetDescription]:
def eventFilter(self, obj, event):
# type: (QObject, QEvent) -> bool
# Filter the scene's drag/drop events.
- if obj is self.scene():
+ scene = self.currentScene()
+ if obj is scene:
etype = event.type()
if etype == QEvent.GraphicsSceneDragEnter or \
etype == QEvent.GraphicsSceneDragMove:
@@ -1347,8 +1510,8 @@ def eventFilter(self, obj, event):
drop_target = None
desc = self.__desc_from_mime_data(event.mimeData())
if desc is not None:
- item = self.__scene.item_at(event.scenePos(), items.LinkItem)
- link = self.scene().link_for_item(item) if item else None
+ item = scene.item_at(event.scenePos(), items.LinkItem)
+ link = 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)
@@ -1369,8 +1532,8 @@ def eventFilter(self, obj, event):
if desc is not None:
statistics = self.usageStatistics()
pos = event.scenePos()
- item = self.__scene.item_at(event.scenePos(), items.LinkItem)
- link = self.scene().link_for_item(item) if item else None
+ item = scene.item_at(event.scenePos(), items.LinkItem)
+ link = scene.link_for_item(item) if item else None
if link and can_insert_node(desc, link):
statistics.begin_insert_action(True, link)
node = self.newNodeHelper(desc, position=(pos.x(), pos.y()))
@@ -1390,7 +1553,14 @@ def eventFilter(self, obj, event):
elif etype == QEvent.GraphicsSceneDrop:
return self.sceneDropEvent(event)
elif etype == QEvent.GraphicsSceneMousePress:
- self.__pasteOrigin = event.scenePos()
+ if event.button() == Qt.BackButton:
+ self.__historyStep(-1)
+ elif event.button() == Qt.ForwardButton:
+ self.__historyStep(1)
+ elif event.button() in (
+ Qt.LeftButton, Qt.RightButton, Qt.MiddleButton
+ ):
+ self.__pasteOrigin = event.scenePos()
return self.sceneMousePressEvent(event)
elif etype == QEvent.GraphicsSceneMouseMove:
return self.sceneMouseMoveEvent(event)
@@ -1410,14 +1580,34 @@ def eventFilter(self, obj, event):
# Re post the event
self.__showHelpFor(event.href())
elif event.type() == WorkflowEvent.ActivateParentRequest:
+ node = event.node()
+ self.__historyPush(node.parent_node())
+ self.ensureVisible(node)
self.window().activateWindow()
self.window().raise_()
+ elif event.type() == WorkflowEvent.NodeRemoved:
+ node = event.node()
+ # meta node removed, if the current displayed is this or a
+ # child thereof we must ensure to change display to the
+ # nodes's parent
+ if isinstance(node, MetaNode) \
+ and self.__root in [node, *node.all_nodes()]:
+ nodes = [node, *node.all_nodes()]
+ del self.__history[self.__historyIndex + 1:]
+ # filter all nodes from history,
+ self.__history = [n for n in self.__history if n not in nodes]
+ self.__historyIndex = len(self.__history) - 1
+ self.openMetaNode(self.__history[self.__historyIndex])
+ if isinstance(node, MetaNode) and node in self.__scenes:
+ scene = self.__scenes.pop(node)
+ self.__teardownScene(scene)
+ scene.deleteLater()
return super().eventFilter(obj, event)
def sceneMousePressEvent(self, event):
# type: (QGraphicsSceneMouseEvent) -> bool
- scene = self.__scene
+ scene = self.currentScene()
if scene.user_interaction_handler:
return False
@@ -1434,7 +1624,7 @@ def sceneMousePressEvent(self, event):
link_item = scene.item_at(pos, items.LinkItem)
if link_item and event.button() == Qt.MiddleButton:
- link = self.scene().link_for_item(link_item)
+ link = self.currentScene().link_for_item(link_item)
self.removeLink(link)
event.accept()
return True
@@ -1470,7 +1660,7 @@ def sceneMousePressEvent(self, event):
if any_item and event.button() == Qt.LeftButton:
self.__possibleMouseItemsMove = True
self.__itemsMoving.clear()
- self.__scene.node_item_position_changed.connect(
+ scene.node_item_position_changed.connect(
self.__onNodePositionChanged
)
self.__annotationGeomChanged.mappedObject.connect(
@@ -1483,7 +1673,7 @@ def sceneMousePressEvent(self, event):
def sceneMouseMoveEvent(self, event):
# type: (QGraphicsSceneMouseEvent) -> bool
- scene = self.__scene
+ scene = self.currentScene()
if scene.user_interaction_handler:
return False
@@ -1501,13 +1691,13 @@ def sceneMouseMoveEvent(self, event):
def sceneMouseReleaseEvent(self, event):
# type: (QGraphicsSceneMouseEvent) -> bool
- scene = self.__scene
+ scene = self.currentScene()
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(
+ scene.node_item_position_changed.disconnect(
self.__onNodePositionChanged
)
self.__annotationGeomChanged.mappedObject.disconnect(
@@ -1517,17 +1707,17 @@ def sceneMouseReleaseEvent(self, event):
set_enabled_all(self.__disruptiveActions, True)
if self.__itemsMoving:
- self.__scene.mouseReleaseEvent(event)
+ 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):
+ if isinstance(scheme_item, Node):
command = commands.MoveNodeCommand(
scheme, scheme_item, old, new
)
- elif isinstance(scheme_item, BaseSchemeAnnotation):
+ elif isinstance(scheme_item, Annotation):
command = commands.AnnotationGeometryChange(
scheme, scheme_item, old, new
)
@@ -1546,13 +1736,13 @@ def sceneMouseReleaseEvent(self, event):
def sceneMouseDoubleClickEvent(self, event):
# type: (QGraphicsSceneMouseEvent) -> bool
- scene = self.__scene
+ scene = self.currentScene()
if scene.user_interaction_handler:
return False
item = scene.item_at(event.scenePos())
- if not item and self.__quickMenuTriggers & \
- SchemeEditWidget.DoubleClicked:
+ if not item and event.button() == Qt.LeftButton and \
+ self.__quickMenuTriggers & SchemeEditWidget.DoubleClicked:
# Double click on an empty spot
# Create a new node using QuickMenu
action = interactions.NewNodeAction(self)
@@ -1569,7 +1759,7 @@ def sceneKeyPressEvent(self, event):
# type: (QKeyEvent) -> bool
self.__updateOpenWidgetAnchors(event)
- scene = self.__scene
+ scene = self.currentScene()
if scene.user_interaction_handler:
return False
@@ -1622,7 +1812,6 @@ def sceneKeyReleaseEvent(self, event):
def __updateOpenWidgetAnchors(self, event=None):
if self.__openAnchorsMode == SchemeEditWidget.OpenAnchors.Never:
return
- scene = self.__scene
mode = self.__openAnchorsMode
# Open widget anchors on shift. New link action should work during this
if event:
@@ -1630,20 +1819,22 @@ def __updateOpenWidgetAnchors(self, event=None):
else:
shift_down = QApplication.keyboardModifiers() == Qt.ShiftModifier
if mode == SchemeEditWidget.OpenAnchors.Never:
- scene.set_widget_anchors_open(False)
+ opened = False
elif mode == SchemeEditWidget.OpenAnchors.OnShift:
- scene.set_widget_anchors_open(shift_down)
+ opened = shift_down
else:
- scene.set_widget_anchors_open(True)
+ opened = True
+ for scene in self.__scenes.values():
+ scene.set_widget_anchors_open(opened)
def sceneContextMenuEvent(self, event):
# type: (QGraphicsSceneContextMenuEvent) -> bool
scenePos = event.scenePos()
globalPos = event.screenPos()
- item = self.scene().item_at(scenePos, items.NodeItem)
+ item = self.currentScene().item_at(scenePos, items.NodeItem)
if item is not None:
- node = self.scene().node_for_item(item) # type: SchemeNode
+ node = self.currentScene().node_for_item(item)
actions = [] # type: List[QAction]
manager = self.widgetManager()
if manager is not None:
@@ -1666,15 +1857,15 @@ def sceneContextMenuEvent(self, event):
menu.popup(globalPos)
return True
- item = self.scene().item_at(scenePos, items.LinkItem)
+ item = self.currentScene().item_at(scenePos, items.LinkItem)
if item is not None:
- link = self.scene().link_for_item(item)
+ link = self.currentScene().link_for_item(item)
self.__linkEnableAction.setChecked(link.enabled)
self.__contextMenuTarget = link
self.__linkMenu.popup(globalPos)
return True
- item = self.scene().item_at(scenePos)
+ item = self.currentScene().item_at(scenePos)
if not item and \
self.__quickMenuTriggers & SchemeEditWidget.RightClicked:
action = interactions.NewNodeAction(self)
@@ -1708,21 +1899,41 @@ def sceneDropEvent(self, event: QGraphicsSceneDragDropEvent) -> bool:
UNUSED(event)
return False
- def _userInteractionHandler(self):
- return self.__scene.user_interaction_handler
+ def event(self, event):
+ if event.type() == QEvent.Gesture:
+ self.gestureEvent(cast(QGestureEvent, event))
+ return super().event(event)
+
+ def gestureEvent(self, event: QGestureEvent):
+ gesture = event.gesture(Qt.SwipeGesture)
+ if gesture is not None:
+ self.swipeGestureEvent(event)
+
+ def swipeGestureEvent(self, event: QGestureEvent):
+ gesture = event.gesture(Qt.SwipeGesture)
+ if gesture.horizontalDirection() == QSwipeGesture.Left and \
+ gesture.state() == Qt.GestureFinished:
+ self.__historyStep(-1)
+ elif gesture.horizontalDirection() == QSwipeGesture.Right and \
+ gesture.state() == Qt.GestureFinished:
+ self.__historyStep(1)
+
+ def _userInteractionHandler(self) -> Optional[UserInteraction]:
+ return self.currentScene().user_interaction_handler
def _setUserInteractionHandler(self, handler):
- # type: (Optional[interactions.UserInteraction]) -> None
+ # type: (Optional[UserInteraction]) -> None
"""
Helper method for setting the user interaction handlers.
"""
- if self.__scene.user_interaction_handler:
- if isinstance(self.__scene.user_interaction_handler,
+ scene = self.currentScene()
+ if scene.user_interaction_handler:
+ if isinstance(scene.user_interaction_handler,
(interactions.ResizeArrowAnnotation,
interactions.ResizeTextAnnotation)):
- self.__scene.user_interaction_handler.commit()
+ scene.user_interaction_handler.commit()
- self.__scene.user_interaction_handler.ended.disconnect(
+ scene.user_interaction_handler.ended.disconnect(
self.__onInteractionEnded
)
@@ -1731,7 +1942,7 @@ def _setUserInteractionHandler(self, handler):
# Disable actions which could change the model
set_enabled_all(self.__disruptiveActions, False)
- self.__scene.set_user_interaction_handler(handler)
+ scene.set_user_interaction_handler(handler)
def __onInteractionEnded(self):
# type: () -> None
@@ -1755,6 +1966,8 @@ def __onSelectionChanged(self):
self.__renameAction.setEnabled(len(nodes) == 1)
self.__duplicateSelectedAction.setEnabled(bool(nodes))
self.__copySelectedAction.setEnabled(bool(nodes))
+ self.__createMacroAction.setEnabled(len(nodes) >= 2)
+ self.__expandMacroAction.setEnabled(len(nodes) == 1 and isinstance(nodes[0], MetaNode))
if len(nodes) > 1:
self.__openSelectedAction.setText(self.tr("Open All"))
@@ -1767,7 +1980,7 @@ def __onSelectionChanged(self):
self.__removeSelectedAction.setText(self.tr("Remove"))
focus = self.focusNode()
- if focus is not None:
+ if focus is not None and isinstance(focus, SchemeNode):
desc = focus.description
tip = whats_this_helper(desc, include_more_link=True)
else:
@@ -1781,7 +1994,7 @@ def __onSelectionChanged(self):
QCoreApplication.sendEvent(self, ev)
def __onLinkActivate(self, item):
- link = self.scene().link_for_item(item)
+ link = self.currentScene().link_for_item(item)
action = interactions.EditNodeLinksAction(self, link.source_node,
link.sink_node)
action.edit_links()
@@ -1791,13 +2004,12 @@ def __onLinkAdded(self, item: items.LinkItem) -> None:
def __onNodeActivate(self, item):
# type: (items.NodeItem) -> None
- node = self.__scene.node_for_item(item)
- QCoreApplication.sendEvent(
- node, WorkflowEvent(WorkflowEvent.NodeActivateRequest))
+ node = self.currentScene().node_for_item(item)
+ self.__openNodes([node])
def __onNodePositionChanged(self, item, pos):
# type: (items.NodeItem, QPointF) -> None
- node = self.__scene.node_for_item(item)
+ node = self.currentScene().node_for_item(item)
new = (pos.x(), pos.y())
if node not in self.__itemsMoving:
self.__itemsMoving[node] = (node.position, new)
@@ -1807,7 +2019,7 @@ def __onNodePositionChanged(self, item, pos):
def __onAnnotationGeometryChanged(self, item):
# type: (AnnotationItem) -> None
- annot = self.scene().annotation_for_item(item)
+ annot = self.currentScene().annotation_for_item(item)
if annot not in self.__itemsMoving:
self.__itemsMoving[annot] = (annot.geometry,
geometry_from_annotation_item(item))
@@ -1856,11 +2068,10 @@ def __onAnnotationRemoved(self, item):
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:
+ if not self._userInteractionHandler():
self.__startControlPointEdit(newFocusItem)
def __onEditingFinished(self, item):
@@ -1868,8 +2079,8 @@ def __onEditingFinished(self, item):
"""
Text annotation editing has finished.
"""
- annot = self.__scene.annotation_for_item(item)
- assert isinstance(annot, SchemeTextAnnotation)
+ annot = self.currentScene().annotation_for_item(item)
+ assert isinstance(annot, TextAnnotation)
content_type = item.contentType()
content = item.content()
@@ -1893,7 +2104,7 @@ def __toggleNewArrowAnnotation(self, checked):
if not checked:
# The action was unchecked (canceled by the user)
- handler = self.__scene.user_interaction_handler
+ handler = self._userInteractionHandler()
if isinstance(handler, interactions.NewArrowAnnotation):
# Cancel the interaction and restore the state
handler.ended.disconnect(action.toggle)
@@ -1917,7 +2128,7 @@ def __onFontSizeTriggered(self, action):
self.__newTextAnnotationAction.trigger()
else:
# Update the preferred font on the interaction handler.
- handler = self.__scene.user_interaction_handler
+ handler = self._userInteractionHandler()
if isinstance(handler, interactions.NewTextAnnotation):
handler.setFont(action.font())
@@ -1931,7 +2142,7 @@ def __toggleNewTextAnnotation(self, checked):
if not checked:
# The action was unchecked (canceled by the user)
- handler = self.__scene.user_interaction_handler
+ handler = self._userInteractionHandler()
if isinstance(handler, interactions.NewTextAnnotation):
# cancel the interaction and restore the state
handler.ended.disconnect(action.toggle)
@@ -1955,7 +2166,7 @@ def __onArrowColorTriggered(self, action):
self.__newArrowAnnotationAction.trigger()
else:
# Update the preferred color on the interaction handler
- handler = self.__scene.user_interaction_handler
+ handler = self._userInteractionHandler()
if isinstance(handler, interactions.NewArrowAnnotation):
handler.setColor(action.data())
@@ -1975,7 +2186,7 @@ def __onHelpAction(self):
"""
nodes = self.selectedNodes()
help_url = None
- if len(nodes) == 1:
+ if len(nodes) == 1 and isinstance(nodes[0], SchemeNode):
node = nodes[0]
desc = node.description
@@ -2080,11 +2291,42 @@ def __duplicateSelected(self):
"""
Duplicate currently selected nodes.
"""
- nodedups, linkdups = self.__copySelected()
- if not nodedups:
+ scheme = self.scheme()
+ if scheme is None:
return
+ # ensure up to date node properties (settings)
+ scheme.sync_node_properties()
- pos = nodes_top_left(nodedups)
+ def duplicate(root: MetaNode, nodes: Sequence[Node], links: Sequence[Link]):
+ # links between nodes
+ links_between = [
+ link for link in root.links()
+ if link.source_node in nodes and link.sink_node in nodes
+ ]
+ # selected links that are connected to nodes on one side
+ links_ = [link for link in links
+ if link.sink_node in nodes and
+ link.source_node not in nodes]
+ links_ += [link for link in links
+ if link.source_node in nodes and
+ link.sink_node not in nodes and
+ not link.sink_channel.single]
+
+ nodes_other = set(root.nodes()) - set(nodes)
+ memo = {id(node): node for node in nodes_other}
+ links = links_between + links_
+ # ensure links are in the same relative order as in root.links()
+ order = {link: i for i, link in enumerate(links)}
+ links = sorted(links, key=order.get)
+ # deepcopied nodes and links
+ nodedups, linkdups = copy.deepcopy((nodes, links), memo=memo)
+ return nodedups, linkdups
+ nodedups, linkdups = duplicate(
+ self.__root, self.selectedNodes(), self.selectedLinks()
+ )
+ if not nodedups:
+ return
+ pos = nodes_bounding_box(nodedups).topLeft()
self.__paste(nodedups, linkdups, pos + DuplicateOffset,
commandname=self.tr("Duplicate"))
@@ -2112,7 +2354,7 @@ def __copyToClipboard(self):
mime = QMimeData()
mime.setData(MimeTypeWorkflowFragment, buff.getvalue())
cb.setMimeData(mime)
- self.__pasteOrigin = nodes_top_left(nodes) + DuplicateOffset
+ self.__pasteOrigin = nodes_bounding_box(nodes).topLeft() + DuplicateOffset
def __updatePasteActionState(self):
self.__pasteAction.setEnabled(
@@ -2124,6 +2366,7 @@ def __copySelected(self):
Return a deep copy of currently selected nodes and links between them.
"""
scheme = self.scheme()
+ root = self.root()
if scheme is None:
return [], []
@@ -2132,17 +2375,11 @@ def __copySelected(self):
# 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]
+ links = [link for link in root.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]
-
+ nodedups, linkdups = copy.deepcopy((nodes, links))
return nodedups, linkdups
def __pasteFromClipboard(self):
@@ -2157,7 +2394,13 @@ def __pasteFromClipboard(self):
log.error("pasteFromClipboard:", exc_info=True)
QApplication.beep()
return
- self.__paste(sch.nodes, sch.links, self.__pasteOrigin)
+ root = sch.root()
+ # take/remove all nodes and links
+ links = root.links()
+ nodes = root.nodes()
+ apply_all(root.remove_link, links)
+ apply_all(root.remove_node, nodes)
+ self.__paste(nodes, links, self.__pasteOrigin)
self.__pasteOrigin = self.__pasteOrigin + DuplicateOffset
def __paste(self, nodedups, linkdups, pos: Optional[QPointF] = None,
@@ -2170,8 +2413,7 @@ def __paste(self, nodedups, linkdups, pos: Optional[QPointF] = None,
return
# find unique names for new nodes
- allnames = {node.title for node in scheme.nodes}
-
+ allnames = {node.title for node in scheme.all_nodes()}
for nodedup in nodedups:
nodedup.title = uniquify(
remove_copy_number(nodedup.title), allnames,
@@ -2181,7 +2423,7 @@ def __paste(self, nodedups, linkdups, pos: Optional[QPointF] = None,
if pos is not None:
# top left of nodedups brect
- origin = nodes_top_left(nodedups)
+ origin = nodes_bounding_box(nodedups).topLeft()
delta = pos - origin
# move nodedups to be relative to pos
for nodedup in nodedups:
@@ -2196,25 +2438,75 @@ def __paste(self, nodedups, linkdups, pos: Optional[QPointF] = None,
macrocommands = []
for nodedup in nodedups:
macrocommands.append(
- commands.AddNodeCommand(scheme, nodedup, parent=command))
+ commands.AddNodeCommand(scheme, nodedup, self.__root, parent=command))
for linkdup in linkdups:
macrocommands.append(
- commands.AddLinkCommand(scheme, linkdup, parent=command))
+ commands.AddLinkCommand(scheme, linkdup, self.__root, parent=command))
statistics = self.usageStatistics()
statistics.begin_action(UsageStatistics.Duplicate)
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)
+ # select the pasted elements
+ self.setSelection(nodedups + linkdups)
+
+ def __createMacro(self, nodes: List['Node']) -> MetaNode:
+ assert nodes
+ model = self.__scheme
+ parent = self.__root
+ assert model is not None
+ assert parent is not None
+ res = prepare_macro_patch(parent, nodes)
+ stack = self.__undoStack
+ stack.beginMacro(self.tr("Create Macro Node"))
+ for link in res.removed_links:
+ stack.push(commands.RemoveLinkCommand(model, link, parent))
+ for node in res.nodes:
+ stack.push(commands.RemoveNodeCommand(model, node, parent))
+ macro = res.macro_node
+ stack.push(commands.AddNodeCommand(model, macro, parent))
+ for node in res.nodes:
+ stack.push(commands.AddNodeCommand(model, node, macro))
+ for link in res.links:
+ stack.push(commands.AddLinkCommand(model, link, macro))
+ for link in itertools.chain(res.output_links, res.input_links):
+ stack.push(commands.AddLinkCommand(model, link, parent))
+ stack.endMacro()
+ return macro
+
+ def createMacroFromSelection(self):
+ """Create a macro node from the current selection."""
+ selection = self.selectedNodes()
+ if not selection:
+ return
+ macro = self.__createMacro(selection)
+ scene = self.currentScene()
+ item = scene.item_for_node(macro)
+ if item is not None:
+ scene.clearSelection()
item.setSelected(True)
+ self.editNodeTitle(macro)
+
+ def __expandMacroFromSelection(self):
+ nodes = self.selectedNodes()
+ if len(nodes) == 1:
+ node = nodes[0]
+ if isinstance(node, MetaNode):
+ self.expandMacro(node)
+
+ def expandMacro(self, macro: MetaNode):
+ model = self.__scheme
+ parent = self.__root
+ assert model is not None
+ assert parent is not None
+ res = prepare_expand_macro(parent, macro)
+ stack = self.__undoStack
+ stack.beginMacro(self.tr("Expand Macro Node"))
+ stack.push(commands.RemoveNodeCommand(model, macro, parent))
+ for node in res.nodes:
+ stack.push(commands.AddNodeCommand(model, node, parent))
+ for link in res.links:
+ stack.push(commands.AddLinkCommand(model, link, parent))
+ stack.endMacro()
def __startControlPointEdit(self, item):
# type: (items.annotationitem.Annotation) -> None
@@ -2239,7 +2531,7 @@ def __endControlPointEdit(self):
"""
End the current control point edit interaction.
"""
- handler = self.__scene.user_interaction_handler
+ handler = self._userInteractionHandler()
if isinstance(handler, (interactions.ResizeArrowAnnotation,
interactions.ResizeTextAnnotation)) and \
not handler.isFinished() and not handler.isCanceled():
@@ -2248,23 +2540,6 @@ def __endControlPointEdit(self):
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:
@@ -2312,8 +2587,7 @@ def __saveWindowGroup(self):
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 = SaveWindowGroup(self, windowTitle="Save Group as...")
dlg.setWindowModality(Qt.ApplicationModal)
dlg.setItems(items)
if default:
@@ -2340,9 +2614,9 @@ def store_group():
g.default = False
if idx == -1:
- text = "Store Window Group"
+ text = self.tr("Store Window Group")
else:
- text = "Update Window Group"
+ text = self.tr("Update Window Group")
self.__undoStack.push(
commands.SetWindowGroupPresets(workflow, newpresets, text=text)
@@ -2365,7 +2639,7 @@ def __clearWindowGroups(self):
return
self.__undoStack.push(
commands.SetWindowGroupPresets(
- workflow, [], text="Delete All Window Groups")
+ workflow, [], text=self.tr("Delete All Window Groups"))
)
def __raiseToFont(self):
@@ -2397,105 +2671,6 @@ def widgetManager(self):
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.AdjustToMinimumContentsLengthWithIcon,
- 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()
@@ -2526,28 +2701,16 @@ def set_enabled_all(objects, enable):
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) -> Dict[str, Dict[str, Any]]
scheme.sync_node_properties()
return {
- node: dict(node.properties) for node in scheme.nodes
+ node: dict(node.properties) for node in scheme.all_nodes()
}
def can_insert_node(new_node_desc, original_link):
- # type: (WidgetDescription, SchemeLink) -> bool
+ # type: (WidgetDescription, Link) -> bool
return any(any(scheme.compatible_channels(output, input)
for input in new_node_desc.inputs)
for output in original_link.source_node.output_channels()) and \
@@ -2565,70 +2728,3 @@ def remove_copy_number(name):
if match:
return name[:match.start()]
return name
-
-
-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,
- itertools.chain((item,), 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 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())
diff --git a/orangecanvas/document/suggestions.py b/orangecanvas/document/suggestions.py
index 289f5651b..69ca0a93c 100644
--- a/orangecanvas/document/suggestions.py
+++ b/orangecanvas/document/suggestions.py
@@ -54,6 +54,9 @@ def overwrite_probabilities_with_frequencies(self):
self.increment_probability(link[0], link[1], link[2], count)
def new_link(self, link):
+ if not hasattr(link.source_node, "description") or \
+ not hasattr(link.sink_node, "description"):
+ return
# direction is none when a widget was not added+linked via quick menu
if self.__direction is None:
return
diff --git a/orangecanvas/document/tests/test_commands.py b/orangecanvas/document/tests/test_commands.py
new file mode 100644
index 000000000..f36635802
--- /dev/null
+++ b/orangecanvas/document/tests/test_commands.py
@@ -0,0 +1,111 @@
+import unittest
+from types import SimpleNamespace
+
+from AnyQt.QtWidgets import QUndoStack
+
+from orangecanvas.document import commands
+from orangecanvas.registry.tests import small_testing_registry
+from orangecanvas.scheme import Scheme, SchemeNode, MetaNode, Link
+
+
+class TestCommands(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ super().setUpClass()
+ cls.reg = small_testing_registry()
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ del cls.reg
+ super().tearDownClass()
+
+ def setUp(self):
+ super().setUp()
+ self.stack = QUndoStack()
+ self.workflow = Scheme()
+ self.root = self.workflow.root()
+
+ def tearDown(self) -> None:
+ del self.stack
+ del self.workflow
+ del self.root
+ super().tearDown()
+
+ def test_add_node_command(self):
+ node = SchemeNode(self.reg.widget("one"))
+ command = commands.AddNodeCommand(self.workflow, node, self.root)
+ self.stack.push(command)
+ self.assertSequenceEqual(self.root.nodes(), [node])
+ self.stack.undo()
+ self.assertSequenceEqual(self.root.nodes(), [])
+
+ def test_remove_node_command(self):
+ node = SchemeNode(self.reg.widget("one"))
+ self.root.add_node(node)
+ command = commands.RemoveNodeCommand(self.workflow, node, self.root)
+ self.stack.push(command)
+ self.assertSequenceEqual(self.root.nodes(), [])
+ self.stack.undo()
+ self.assertSequenceEqual(self.root.nodes(), [node])
+
+ @classmethod
+ def _setup_workflow_with_macro(cls, workflow: Scheme):
+ root = workflow.root()
+ one1 = SchemeNode(cls.reg.widget("one"))
+ one2 = SchemeNode(cls.reg.widget("one"))
+ add = SchemeNode(cls.reg.widget("add"))
+ neg = SchemeNode(cls.reg.widget("negate"))
+ meta = MetaNode()
+ i1 = meta.create_input_node(add.input_channels()[0])
+ o1 = meta.create_output_node(add.output_channels()[0])
+ meta.add_node(add)
+ meta.add_node(one2)
+ meta.add_link(
+ Link(i1, i1.output_channels()[0], add, add.input_channels()[0]))
+ meta.add_link(
+ Link(one2, one2.output_channels()[0], add, add.input_channels()[1]))
+ meta.add_link(
+ Link(add, add.output_channels()[0], o1, o1.input_channels()[0]))
+
+ root.add_node(one1)
+ root.add_node(meta)
+ root.add_node(neg)
+ l1 = Link(one1, one1.output_channels()[0], meta, meta.input_channels()[0])
+ root.add_link(l1)
+ l2 = Link(meta, meta.output_channels()[0], neg, neg.input_channels()[0])
+ root.add_link(l2)
+ return SimpleNamespace(
+ root=root, one1=one1, one2=one2, add=add, neg=neg, meta=meta,
+ i1=i1, o1=o1, l1=l1, l2=l2)
+
+ def test_remove_macro_node(self):
+ ns = self._setup_workflow_with_macro(self.workflow)
+ command = commands.RemoveNodeCommand(self.workflow, ns.meta, ns.root)
+ self.stack.push(command)
+ self.assertSequenceEqual(self.root.nodes(), [ns.one1, ns.neg])
+ self.assertSequenceEqual(self.root.links(), [])
+ self.assertIs(ns.meta.parent_node(), None)
+ self.stack.undo()
+ self.assertIs(ns.meta.parent_node(), self.root)
+ self.assertSequenceEqual(self.root.nodes(), [ns.one1, ns.meta, ns.neg])
+ self.assertSequenceEqual(self.root.links(), [ns.l1, ns.l2])
+
+ def test_remove_input_node(self):
+ ns = self._setup_workflow_with_macro(self.workflow)
+ command = commands.RemoveNodeCommand(self.workflow, ns.i1, ns.meta)
+ self.stack.push(command)
+ self.assertSequenceEqual(self.root.links(), [ns.l2])
+ self.assertSequenceEqual(ns.meta.input_channels(), [])
+ self.stack.undo()
+ self.assertSequenceEqual(self.root.links(), [ns.l1, ns.l2])
+ self.assertSequenceEqual(ns.meta.input_channels(), [ns.i1.input_channels()[0]])
+
+ def test_remove_output_node(self):
+ ns = self._setup_workflow_with_macro(self.workflow)
+ command = commands.RemoveNodeCommand(self.workflow, ns.o1, ns.meta)
+ self.stack.push(command)
+ self.assertSequenceEqual(self.root.links(), [ns.l1])
+ self.assertSequenceEqual(ns.meta.output_channels(), [])
+ self.stack.undo()
+ self.assertSequenceEqual(self.root.links(), [ns.l1, ns.l2])
+ self.assertSequenceEqual(ns.meta.output_channels(), [ns.o1.output_channels()[0]])
diff --git a/orangecanvas/document/tests/test_editlinksdialog.py b/orangecanvas/document/tests/test_editlinksdialog.py
index 55f3af8e3..5b7b7b3b7 100644
--- a/orangecanvas/document/tests/test_editlinksdialog.py
+++ b/orangecanvas/document/tests/test_editlinksdialog.py
@@ -50,7 +50,7 @@ def test_editlinksnode(self):
scene.addItem(node)
node = EditLinksNode(direction=Qt.RightToLeft)
- node.setSchemeNode(sink_node)
+ node.setNode(sink_node)
node.setPos(300, 0)
scene.addItem(node)
diff --git a/orangecanvas/document/tests/test_schemeedit.py b/orangecanvas/document/tests/test_schemeedit.py
index bbc97e165..d7444f405 100644
--- a/orangecanvas/document/tests/test_schemeedit.py
+++ b/orangecanvas/document/tests/test_schemeedit.py
@@ -21,7 +21,7 @@
)
from ...canvas import items
from ...scheme import Scheme, SchemeNode, SchemeLink, SchemeTextAnnotation, \
- SchemeArrowAnnotation
+ SchemeArrowAnnotation, MetaNode
from ...registry.tests import small_testing_registry
from ...gui.test import QAppTestCase, mouseMove, dragDrop, dragEnterLeave, \
contextMenu
@@ -70,6 +70,7 @@ def test_schemeedit(self):
self.assertFalse(w.isModified())
scheme = Scheme()
+ root = scheme.root()
w.setScheme(scheme)
self.assertIs(w.scheme(), scheme)
@@ -97,7 +98,7 @@ def test_schemeedit(self):
w.addNode(node)
self.assertSequenceEqual(node_list, [node])
- self.assertSequenceEqual(scheme.nodes, node_list)
+ self.assertSequenceEqual(root.nodes(), node_list)
self.assertTrue(w.isModified())
@@ -105,7 +106,7 @@ def test_schemeedit(self):
stack.undo()
self.assertSequenceEqual(node_list, [])
- self.assertSequenceEqual(scheme.nodes, node_list)
+ self.assertSequenceEqual(root.nodes(), node_list)
self.assertTrue(not w.isModified())
stack.redo()
@@ -114,7 +115,7 @@ def test_schemeedit(self):
w.addNode(node1)
self.assertSequenceEqual(node_list, [node, node1])
- self.assertSequenceEqual(scheme.nodes, node_list)
+ self.assertSequenceEqual(root.nodes(), node_list)
self.assertTrue(w.isModified())
link = SchemeLink(node, "value", node1, "value")
@@ -225,7 +226,7 @@ def test_node_rename(self):
@unittest.skipUnless(sys.platform == "darwin", "macos only")
def test_node_rename_click_selected(self):
w = self.w
- scene = w.scene()
+ scene = w.currentScene()
view = w.view()
w.show()
w.raise_()
@@ -246,14 +247,15 @@ def test_arrow_annotation_action(self):
w = self.w
workflow = w.scheme()
workflow.clear()
+ root = workflow.root()
view = w.view()
actions = w.toolbarActions()
action_by_name(actions, "new-arrow-action").trigger()
QTest.mousePress(view.viewport(), Qt.LeftButton, pos=QPoint(50, 50))
mouseMove(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100))
QTest.mouseRelease(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100))
- self.assertEqual(len(workflow.annotations), 1)
- self.assertIsInstance(workflow.annotations[0], SchemeArrowAnnotation)
+ self.assertEqual(len(root.annotations()), 1)
+ self.assertIsInstance(root.annotations()[0], SchemeArrowAnnotation)
def test_arrow_annotation_action_cancel(self):
w = self.w
@@ -272,7 +274,7 @@ def test_arrow_annotation_action_cancel(self):
mouseMove(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100))
QTest.keyClick(view.viewport(), Qt.Key_Escape)
self.assertFalse(action.isChecked())
- self.assertEqual(workflow.annotations, [])
+ self.assertEqual(workflow.root().annotations(), [])
def test_text_annotation_action(self):
w = self.w
@@ -285,10 +287,10 @@ def test_text_annotation_action(self):
mouseMove(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100))
QTest.mouseRelease(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100))
# need to steal focus from the item for it to be commited.
- w.scene().setFocusItem(None)
+ w.currentScene().setFocusItem(None)
- self.assertEqual(len(workflow.annotations), 1)
- self.assertIsInstance(workflow.annotations[0], SchemeTextAnnotation)
+ self.assertEqual(len(workflow.root().annotations()), 1)
+ self.assertIsInstance(workflow.root().annotations()[0], SchemeTextAnnotation)
def test_text_annotation_action_cancel(self):
w = self.w
@@ -307,8 +309,8 @@ def test_text_annotation_action_cancel(self):
mouseMove(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100))
QTest.keyClick(view.viewport(), Qt.Key_Escape)
self.assertFalse(action.isChecked())
- w.scene().setFocusItem(None)
- self.assertEqual(workflow.annotations, [])
+ w.currentScene().setFocusItem(None)
+ self.assertEqual(workflow.root().annotations(), [])
def test_path(self):
w = self.w
@@ -333,16 +335,14 @@ def test_select(self):
w = self.w
self.setup_test_workflow(w.scheme())
w.selectAll()
- self.assertSequenceEqual(
- w.selectedNodes(), w.scheme().nodes)
- self.assertSequenceEqual(
- w.selectedAnnotations(), w.scheme().annotations)
- self.assertSequenceEqual(
- w.selectedLinks(), w.scheme().links)
+ root = w.root()
+ self.assertSequenceEqual(w.selectedNodes(), root.nodes())
+ self.assertSequenceEqual(w.selectedAnnotations(), root.annotations())
+ self.assertSequenceEqual(w.selectedLinks(), root.links())
w.removeSelected()
- self.assertEqual(w.scheme().nodes, [])
- self.assertEqual(w.scheme().annotations, [])
- self.assertEqual(w.scheme().links, [])
+ self.assertEqual(root.nodes(), [])
+ self.assertEqual(root.annotations(), [])
+ self.assertEqual(root.links(), [])
def test_select_remove_link(self):
def link_curve(link: SchemeLink) -> QPainterPath:
@@ -351,16 +351,17 @@ def link_curve(link: SchemeLink) -> QPainterPath:
return item.mapToScene(path)
w = self.w
workflow = self.setup_test_workflow(w.scheme())
+ root = workflow.root()
w.alignToGrid()
- scene, view = w.scene(), w.view()
- link = workflow.links[0]
+ scene, view = w.currentScene(), w.view()
+ link = root.links()[0]
path = link_curve(link)
p = path.pointAtPercent(0.5)
QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=view.mapFromScene(p))
self.assertSequenceEqual(w.selectedLinks(), [link])
w.removeSelected()
self.assertSequenceEqual(w.selectedLinks(), [])
- self.assertTrue(link not in workflow.links)
+ self.assertTrue(link not in root.links())
def test_open_selected(self):
w = self.w
@@ -371,14 +372,15 @@ def test_open_selected(self):
def test_insert_node_on_link(self):
w = self.w
workflow = self.setup_test_workflow(w.scheme())
+ root = workflow.root()
neg = SchemeNode(self.reg.widget("negate"))
- target = workflow.links[0]
+ target = root.links()[0]
spyrem = QSignalSpy(workflow.link_removed)
spyadd = QSignalSpy(workflow.link_added)
w.insertNode(neg, target)
- self.assertEqual(workflow.nodes[-1], neg)
+ self.assertEqual(root.nodes()[-1], neg)
- self.assertSequenceEqual(list(spyrem), [[target]])
+ self.assertSequenceEqual(list(spyrem), [[target, workflow.root()]])
self.assertEqual(len(spyadd), 2)
w.undoStack().undo()
@@ -390,42 +392,45 @@ def test_align_to_grid(self):
def test_activate_node(self):
w = self.w
workflow = self.setup_test_workflow()
+ root = workflow.root()
w.setScheme(workflow)
- view, scene = w.view(), w.scene()
- item = scene.item_for_node(workflow.nodes[0]) # type: QGraphicsWidget
+ view, scene = w.view(), w.currentScene()
+ item = scene.item_for_node(root.nodes()[0]) # type: QGraphicsWidget
item.setSelected(True)
item.setFocus(Qt.OtherFocusReason)
- self.assertIs(w.focusNode(), workflow.nodes[0])
+ self.assertIs(w.focusNode(), root.nodes()[0])
item.activated.emit()
def test_duplicate(self):
w = self.w
workflow = self.setup_test_workflow()
+ root = workflow.root()
w.setScheme(workflow)
w.selectAll()
- nnodes, nlinks = len(workflow.nodes), len(workflow.links)
+ nnodes, nlinks = len(root.nodes()), len(root.links())
a = action_by_name(w.actions(), "duplicate-action")
a.trigger()
- self.assertEqual(len(workflow.nodes), 2 * nnodes)
- self.assertEqual(len(workflow.links), 2 * nlinks)
+ self.assertEqual(len(root.nodes()), 2 * nnodes)
+ self.assertEqual(len(root.links()), 2 * nlinks)
w.selectAll()
a.trigger()
- self.assertEqual(len(workflow.nodes), 4 * nnodes)
- self.assertEqual(len(workflow.links), 4 * nlinks)
- self.assertEqual(len(workflow.nodes),
- len(set(n.title for n in workflow.nodes)))
+ self.assertEqual(len(root.nodes()), 4 * nnodes)
+ self.assertEqual(len(root.links()), 4 * nlinks)
+ self.assertEqual(len(root.nodes()),
+ len(set(n.title for n in root.nodes())))
match = re.compile(r"\(\d+\)\s*\(\d+\)")
- self.assertFalse(any(match.search(n.title) for n in workflow.nodes),
+ self.assertFalse(any(match.search(n.title) for n in root.nodes()),
"Duplicated renumbering ('foo (2) (1)')")
def test_copy_paste(self):
w = self.w
workflow = self.setup_test_workflow()
+ root = workflow.root()
w.setRegistry(self.reg)
w.setScheme(workflow)
w.selectAll()
- nnodes, nlinks = len(workflow.nodes), len(workflow.links)
+ nnodes, nlinks = len(root.nodes()), len(root.links())
ca = action_by_name(w.actions(), "copy-action")
cp = action_by_name(w.actions(), "paste-action")
cb = QApplication.clipboard()
@@ -435,8 +440,8 @@ def test_copy_paste(self):
self.assertTrue(spy.wait())
self.assertEqual(len(spy), 1)
cp.trigger()
- self.assertEqual(len(workflow.nodes), 2 * nnodes)
- self.assertEqual(len(workflow.links), 2 * nlinks)
+ self.assertEqual(len(root.nodes()), 2 * nnodes)
+ self.assertEqual(len(root.links()), 2 * nlinks)
w1 = SchemeEditWidget()
w1.setRegistry(self.reg)
@@ -445,41 +450,79 @@ def test_copy_paste(self):
self.assertTrue(cp.isEnabled())
cp.trigger()
wf1 = w1.scheme()
- self.assertEqual(len(wf1.nodes), nnodes)
- self.assertEqual(len(wf1.links), nlinks)
+ root1 = wf1.root()
+ self.assertEqual(len(root1.nodes()), nnodes)
+ self.assertEqual(len(root1.links()), nlinks)
def test_redo_remove_preserves_order(self):
w = self.w
workflow = self.setup_test_workflow()
+ root = workflow.root()
w.setRegistry(self.reg)
w.setScheme(workflow)
undo = w.undoStack()
- links = workflow.links
- nodes = workflow.nodes
- annotations = workflow.annotations
+ links = root.links()
+ nodes = root.nodes()
+ annotations = root.annotations()
assert len(links) > 2
w.removeLink(links[1])
- self.assertSequenceEqual(links[:1] + links[2:], workflow.links)
+ self.assertSequenceEqual(links[:1] + links[2:], root.links())
undo.undo()
- self.assertSequenceEqual(links, workflow.links)
+ self.assertSequenceEqual(links, root.links())
# find add node that has multiple in/out links
- node = findf(workflow.nodes, lambda n: n.title == "add")
+ node = findf(root.nodes(), lambda n: n.title == "add")
w.removeNode(node)
undo.undo()
- self.assertSequenceEqual(links, workflow.links)
- self.assertSequenceEqual(nodes, workflow.nodes)
+ self.assertSequenceEqual(links, root.links())
+ self.assertSequenceEqual(nodes, root.nodes())
w.removeAnnotation(annotations[0])
- self.assertSequenceEqual(annotations[1:], workflow.annotations)
+ self.assertSequenceEqual(annotations[1:], root.annotations())
undo.undo()
- self.assertSequenceEqual(annotations, workflow.annotations)
+ self.assertSequenceEqual(annotations, root.annotations())
+
+ def test_create_macro(self):
+ w = self.w
+ workflow = self.setup_test_workflow()
+ w.setRegistry(self.reg)
+ w.setScheme(workflow)
+ undo = w.undoStack()
+ w.selectAll()
+ w.createMacroFromSelection()
+ undo.undo()
+ self.assertTrue(len(workflow.root().nodes()), 1)
+ undo.redo()
+
+ def test_expand_macro(self):
+ w = self.w
+ workflow, node = self.setup_test_meta_node(w)
+ w.expandMacro(node)
+ self.assertEqual(len(workflow.root().nodes()), 4)
+ undo = w.undoStack()
+ undo.undo()
+ self.assertEqual(len(workflow.root().nodes()), 3)
+ undo.redo()
+
+ def test_open_meta_node(self):
+ w = self.w
+ workflow, node = self.setup_test_meta_node(w)
+ self.assertIs(w.root(), workflow.root())
+ self.assertIs(w.currentScene().root, workflow.root())
+ w.openMetaNode(node)
+ self.assertIs(w.root(), node)
+ self.assertIs(w.currentScene().root, node)
+ undo = w.undoStack()
+ # Undo/remove macro must update the current displayed root
+ undo.undo()
+ self.assertIs(w.root(), workflow.root())
def test_window_groups(self):
w = self.w
workflow = self.setup_test_workflow()
+ nodes = workflow.root().nodes()
workflow.set_window_group_presets([
- Scheme.WindowGroup("G1", False, [(workflow.nodes[0], b'\xff\x00')]),
- Scheme.WindowGroup("G2", True, [(workflow.nodes[0], b'\xff\x00')]),
+ Scheme.WindowGroup("G1", False, [(nodes[0], b'\xff\x00')]),
+ Scheme.WindowGroup("G2", True, [(nodes[0], b'\xff\x00')]),
])
manager = TestingWidgetManager()
workflow.widget_manager = manager
@@ -518,6 +561,7 @@ def test_drop_event(self):
w = self.w
w.setRegistry(self.reg)
workflow = w.scheme()
+ root = workflow.root()
desc = self.reg.widget("one")
viewport = w.view().viewport()
mime = QMimeData()
@@ -528,12 +572,12 @@ def test_drop_event(self):
self.assertTrue(dragDrop(viewport, mime, QPoint(10, 10)))
- self.assertEqual(len(workflow.nodes), 1)
- self.assertEqual(workflow.nodes[0].description, desc)
+ self.assertEqual(len(root.nodes()), 1)
+ self.assertEqual(root.nodes()[0].description, desc)
dragEnterLeave(viewport, mime)
- self.assertEqual(len(workflow.nodes), 1)
+ self.assertEqual(len(root.nodes()), 1)
def test_drag_drop(self):
w = self.w
@@ -582,6 +626,7 @@ def test_plugin_drag_drop(self):
w.setRegistry(self.reg)
w.setDropHandlers([handler])
workflow = w.scheme()
+ root = workflow.root()
viewport = w.view().viewport()
# Test empty handler
mime = QMimeData()
@@ -598,9 +643,9 @@ def test_plugin_drag_drop(self):
dragDrop(viewport, mime, QPoint(10, 10))
self.assertIsNone(w._userInteractionHandler())
- self.assertEqual(len(workflow.nodes), 1)
- self.assertEqual(workflow.nodes[0].description.name, "one")
- self.assertEqual(workflow.nodes[0].properties, {"a": "from drop"})
+ self.assertEqual(len(root.nodes()), 1)
+ self.assertEqual(root.nodes()[0].description.name, "one")
+ self.assertEqual(root.nodes()[0].properties, {"a": "from drop"})
workflow.clear()
@@ -616,9 +661,9 @@ def exec(self, *args):
with mock.patch.object(QMenu, "exec", exec):
dragDrop(viewport, mime, QPoint(10, 10))
- self.assertEqual(len(workflow.nodes), 1)
- self.assertEqual(workflow.nodes[0].description.name, "one")
- self.assertEqual(workflow.nodes[0].properties, {"a": "from drop"})
+ self.assertEqual(len(root.nodes()), 1)
+ self.assertEqual(root.nodes()[0].description.name, "one")
+ self.assertEqual(root.nodes()[0].properties, {"a": "from drop"})
def test_activate_drop_node(self):
class NodeFromMimeData(TestNodeFromMimeData):
@@ -687,6 +732,17 @@ def setup_test_workflow(cls, scheme=None):
scheme.add_annotation(SchemeTextAnnotation((0, 100, 200, 200), "$$"))
return scheme
+ @classmethod
+ def setup_test_meta_node(cls, editor: SchemeEditWidget):
+ workflow = cls.setup_test_workflow()
+ editor.setRegistry(cls.reg)
+ editor.setScheme(workflow)
+ one, add = workflow.root().nodes()[1:3]
+ editor.setSelection([one, add])
+ editor.createMacroFromSelection()
+ node = findf(workflow.root().nodes(), lambda n: isinstance(n, MetaNode))
+ return workflow, node
+
class TestDropHandler(DropHandler):
format_ = "application/prs.test"
diff --git a/orangecanvas/document/tests/test_usagestatistics.py b/orangecanvas/document/tests/test_usagestatistics.py
index 9ac3cedd3..543dbf595 100644
--- a/orangecanvas/document/tests/test_usagestatistics.py
+++ b/orangecanvas/document/tests/test_usagestatistics.py
@@ -10,13 +10,9 @@ def setUp(self):
super().setUp()
self.stats = self.w.current_document().usageStatistics()
self.stats.set_enabled(True)
-
- reg = self.w.scheme_widget._SchemeEditWidget__registry
-
+ reg = self.registry
first_cat = reg.categories()[0]
- data_descriptions = reg.widgets(first_cat)
- self.descs = [reg.action_for_widget(desc).data() for desc in data_descriptions]
-
+ self.descs = reg.widgets(first_cat)
toolbox = self.w.findChild(WidgetToolBox)
widget = toolbox.widget(0)
self.buttons = widget.findChildren(QToolButton)
diff --git a/orangecanvas/document/tests/test_utils.py b/orangecanvas/document/tests/test_utils.py
new file mode 100644
index 000000000..69442edb8
--- /dev/null
+++ b/orangecanvas/document/tests/test_utils.py
@@ -0,0 +1,60 @@
+import unittest
+
+from ..utils import prepare_macro_patch
+from ...registry.tests import small_testing_registry
+from ...scheme import Workflow, SchemeNode, Link
+
+
+class TestMacroUtils(unittest.TestCase):
+ @staticmethod
+ def setup_test_workflow():
+ workflow = Workflow()
+ reg = small_testing_registry()
+
+ zero_desc = reg.widget("zero")
+ one_desc = reg.widget("one")
+ add_desc = reg.widget("add")
+ negate = reg.widget("negate")
+
+ zero_node = SchemeNode(zero_desc)
+ one_node = SchemeNode(one_desc)
+ add_node_1 = SchemeNode(add_desc)
+ add_node_2 = SchemeNode(add_desc)
+ negate_node_1 = SchemeNode(negate)
+ negate_node_2 = SchemeNode(negate)
+
+ workflow.add_node(zero_node)
+ workflow.add_node(one_node)
+ workflow.add_node(add_node_1)
+ workflow.add_node(add_node_2)
+ workflow.add_node(negate_node_1)
+ workflow.add_node(negate_node_2)
+
+ workflow.add_link(Link(zero_node, "value", add_node_1, "left"))
+ workflow.add_link(Link(one_node, "value", add_node_1, "right"))
+ workflow.add_link(Link(zero_node, "value", add_node_2, "left"))
+ workflow.add_link(Link(add_node_1, "result", add_node_2, "right"))
+ workflow.add_link(Link(add_node_1, "result", negate_node_1, "value"))
+ workflow.add_link(Link(add_node_2, "result", negate_node_2, "value"))
+ return workflow
+
+ def test_prepare_macro_patch(self):
+ workflow = self.setup_test_workflow()
+ root = workflow.root()
+ n1, n2, n3, n4, n5, n6 = root.nodes()
+ l1, l2, l3, l4, l5, l6 = root.links()
+ res = prepare_macro_patch(root, [n2, n3, n4])
+ self.assertEqual(res.nodes, [n2, n3, n4])
+ self.assertSetEqual(set(res.removed_links), {l1, l2, l3, l4, l5, l6})
+ li1, li2 = res.input_links
+ self.assertIs(li1.source_node, n1)
+ self.assertIs(li2.source_node, n1)
+ self.assertIsNotNone(li1.sink_channel, li2.sink_channel)
+ self.assertEqual(li1.sink_channel.name, "left (1)")
+ self.assertEqual(li2.sink_channel.name, "left (2)")
+
+ lo1, lo2 = res.output_links
+ self.assertIs(lo1.sink_node, n5)
+ self.assertIs(lo2.sink_node, n6)
+ self.assertEqual(lo1.source_channel.name, "result (1)")
+ self.assertEqual(lo2.source_channel.name, "result (2)")
diff --git a/orangecanvas/document/tests/test_windowgroupsdialog.py b/orangecanvas/document/tests/test_windowgroupsdialog.py
new file mode 100644
index 000000000..90085c409
--- /dev/null
+++ b/orangecanvas/document/tests/test_windowgroupsdialog.py
@@ -0,0 +1,43 @@
+from AnyQt.QtTest import QSignalSpy
+from AnyQt.QtWidgets import QComboBox, QDialogButtonBox, QMessageBox
+
+from orangecanvas.document.windowgroupsdialog import SaveWindowGroup
+from orangecanvas.gui.test import QAppTestCase
+
+
+class TestSaveWindowGroup(QAppTestCase):
+ def test_dialog_default(self):
+ w = SaveWindowGroup()
+ w.setItems(["A", "B", "C"])
+ w.setDefaultIndex(1)
+ self.assertFalse(w.isDefaultChecked())
+ cb = w.findChild(QComboBox)
+ cb.setCurrentIndex(1)
+ self.assertTrue(w.isDefaultChecked())
+
+ def test_dialog_new(self):
+ w = SaveWindowGroup()
+ w.setItems(["A", "B", "C"])
+ cb = w.findChild(QComboBox)
+ bg = w.findChild(QDialogButtonBox)
+ cb.setEditText("D")
+ b = bg.button(QDialogButtonBox.Ok)
+ spy = QSignalSpy(w.finished)
+ # trigger accept
+ b.click()
+ self.assertSequenceEqual(list(spy), [[SaveWindowGroup.Accepted]])
+
+ def test_dialog_overwrite(self):
+ w = SaveWindowGroup()
+ w.setItems(["A", "B", "C"])
+ cb = w.findChild(QComboBox)
+ bg = w.findChild(QDialogButtonBox)
+ cb.setEditText("C")
+ b = bg.button(QDialogButtonBox.Ok)
+ spy = QSignalSpy(w.finished)
+ # trigger accept and simulate confirm overwrite
+ b.click()
+ mb = w.findChild(QMessageBox)
+ mb.done(QMessageBox.Yes)
+ self.assertSequenceEqual(list(spy), [[SaveWindowGroup.Accepted]])
+
diff --git a/orangecanvas/document/usagestatistics.py b/orangecanvas/document/usagestatistics.py
index 0bf16ede7..d9612a164 100644
--- a/orangecanvas/document/usagestatistics.py
+++ b/orangecanvas/document/usagestatistics.py
@@ -10,7 +10,7 @@
from AnyQt.QtCore import QCoreApplication, QSettings
from orangecanvas import config
-from orangecanvas.scheme import SchemeNode, SchemeLink, Scheme
+from orangecanvas.scheme import Node, Link, Scheme
log = logging.getLogger(__name__)
@@ -152,7 +152,7 @@ def begin_extend_action(self, from_sink, extended_widget):
Parameters
----------
from_sink : bool
- extended_widget : SchemeNode
+ extended_widget : Node
"""
if not self.is_enabled():
return
@@ -187,7 +187,7 @@ def begin_insert_action(self, via_drag, original_link):
Parameters
----------
via_drag : bool
- original_link : SchemeLink
+ original_link : Link
"""
if not self.is_enabled():
return
@@ -275,7 +275,7 @@ def log_node_add(self, widget):
Parameters
----------
- widget : SchemeNode
+ widget : Node
"""
if not self.is_enabled():
return
@@ -289,10 +289,11 @@ def log_node_add(self, widget):
event = {
"Type": EventType.NodeAdd,
- "Widget Name": widget.description.id,
- "Widget": widget_id
+ "Widget Name": (
+ widget.description.id if hasattr(widget, "description") else ""
+ ),
+ "Widget": widget_id,
}
-
self._events.append(event)
def log_node_remove(self, widget):
@@ -301,7 +302,7 @@ def log_node_remove(self, widget):
Parameters
----------
- widget : SchemeNode
+ widget : Node
"""
if not self.is_enabled():
return
@@ -326,7 +327,7 @@ def log_link_add(self, link):
Parameters
----------
- link : SchemeLink
+ link : Link
"""
if not self.is_enabled():
return
@@ -339,7 +340,7 @@ def log_link_remove(self, link):
Parameters
----------
- link : SchemeLink
+ link : Link
"""
if not self.is_enabled():
return
@@ -381,17 +382,17 @@ def log_scheme(self, scheme):
if not self.is_enabled():
return
- if not scheme or not scheme.nodes:
+ if not scheme or not scheme.root().nodes():
return
self.begin_action(ActionType.Load)
# first log nodes
- for node in scheme.nodes:
+ for node in scheme.all_nodes():
self.log_node_add(node)
# then log links
- for link in scheme.links:
+ for link in scheme.all_links():
self.log_link_add(link)
self.end_action()
diff --git a/orangecanvas/document/utils.py b/orangecanvas/document/utils.py
new file mode 100644
index 000000000..9aa6236e9
--- /dev/null
+++ b/orangecanvas/document/utils.py
@@ -0,0 +1,239 @@
+import statistics
+from copy import copy
+from contextlib import contextmanager
+from itertools import chain
+from typing import Sequence, Tuple, Generator
+
+from dataclasses import dataclass
+
+from AnyQt.QtCore import QPointF, QRectF
+from AnyQt.QtWidgets import QAction, QUndoStack
+
+from orangecanvas.scheme import Node, Link, MetaNode, OutputNode, InputNode
+from orangecanvas.utils import unique, enumerate_strings
+from orangecanvas.utils.graph import traverse_bf
+
+Pos = Tuple[float, float]
+
+
+@dataclass
+class PrepareMacroPatchResult:
+ #: The created macro node
+ macro_node: MetaNode
+ #: The macro internal nodes
+ nodes: Sequence[Node]
+ #: The macro internal links
+ links: Sequence[Link]
+ #: The new input links to macro_node
+ input_links: Sequence[Link]
+ #: The new output links to the macro_node
+ output_links: Sequence[Link]
+ #: The links that should be removed/replaced by input/output_links.
+ removed_links: Sequence[Link]
+
+
+def prepare_macro_patch(
+ parent: MetaNode, nodes: Sequence[Node]
+) -> PrepareMacroPatchResult:
+ assert all(n.parent_node() is parent for n in nodes)
+ # exclude Input/OutputNodes
+ nodes = [n for n in nodes if not isinstance(n, (InputNode, OutputNode))]
+
+ # complete the nodes with any that lie in between nodes
+ def ancestors(node: Node):
+ return [link.source_node
+ for link in parent.find_links(sink_node=node)]
+
+ def descendants(node: Node):
+ return [link.sink_node
+ for link in parent.find_links(source_node=node)]
+
+ all_ancestors = set(
+ chain.from_iterable(traverse_bf(node, ancestors) for node in nodes)
+ )
+ all_descendants = set(
+ chain.from_iterable(traverse_bf(node, descendants) for node in nodes)
+ )
+ expanded = all_ancestors & all_descendants
+ nodes = list(unique(chain(nodes, expanded)))
+ nodes_bbox = nodes_bounding_box(nodes)
+ inputs_left = nodes_bbox.left() - 200
+ outputs_right = nodes_bbox.right() + 200
+
+ nodes_set = set(nodes)
+ links_internal = [
+ link for link in parent.links()
+ if link.source_node in nodes_set and link.sink_node in nodes_set
+ ]
+ removed_links = list(links_internal)
+ links_in = [
+ link for link in parent.links()
+ if link.source_node not in nodes_set and link.sink_node in nodes_set
+ ]
+ links_out = [
+ link for link in parent.links()
+ if link.source_node in nodes_set and link.sink_node not in nodes_set
+ ]
+ pos = (round(statistics.mean(n.position[0] for n in nodes)),
+ round(statistics.mean(n.position[1] for n in nodes)))
+
+ # group links_in, links_out by node, channel
+ inputs_ = list(unique(
+ map(lambda link: (link.sink_node, link.sink_channel), links_in)))
+ outputs_ = list(unique(
+ map(lambda link: (link.source_node, link.source_channel), links_out)))
+
+ def copy_with_name(channel, name):
+ c = copy(channel)
+ c.name = name
+ return c
+
+ new_names = enumerate_strings(
+ [c.name for _, c in inputs_], pattern="{item} ({_})"
+ )
+ new_inputs_ = [((node, channel), copy_with_name(channel, name=name))
+ for (node, channel), name in zip(inputs_, new_names)]
+ inputs = [_2 for _1, _2 in new_inputs_]
+
+ # new_outputs_ = [(node, channel) for ((node, channel), _) in outputs_]
+ new_names = enumerate_strings(
+ [c.name for _, c in outputs_], pattern="{item} ({_})"
+ )
+ new_outputs_ = [((node, channel), copy_with_name(channel, name=name))
+ for (node, channel), name in zip(outputs_, new_names)]
+ outputs = [_2 for _1, _2 in new_outputs_]
+ new_inputs = dict(new_inputs_); assert len(new_inputs) == len(new_inputs_)
+ new_outputs = dict(new_outputs_); assert len(new_outputs) == len(new_outputs_)
+
+ newnode = MetaNode('Macro', position=pos)
+
+ input_nodes = [newnode.create_input_node(input) for input in inputs]
+ for inode, (node, _) in zip(input_nodes, inputs_):
+ inode.position = (inputs_left, node.position[1])
+ output_nodes = [newnode.create_output_node(output) for output in outputs]
+ for onode, (node, _)in zip(output_nodes, outputs_):
+ onode.position = (outputs_right, node.position[1])
+
+ # relink A -> (InputNode -> B ...)
+ new_input_links = []
+ for i, link in enumerate(links_in):
+ new_input = new_inputs[link.sink_node, link.sink_channel]
+ new_input_links += [
+ Link(link.source_node, link.source_channel, newnode, new_input,
+ enabled=link.enabled)
+ ]
+ for inode, (node, channel) in zip(input_nodes, inputs_):
+ links_internal += [
+ Link(inode, inode.source_channel, node, channel),
+ ]
+
+ # relink (... C -> OutputNode) -> D
+ new_output_links = []
+ for i, link in enumerate(links_out):
+ new_output = new_outputs[link.source_node, link.source_channel]
+ new_output_links += [
+ Link(newnode, new_output, link.sink_node, link.sink_channel,
+ enabled=link.enabled)
+ ]
+ for onode, (node, channel) in zip(output_nodes, outputs_):
+ links_internal += [
+ Link(node, channel, onode, onode.sink_channel),
+ ]
+
+ return PrepareMacroPatchResult(
+ newnode, nodes,
+ links_internal,
+ new_input_links, new_output_links,
+ removed_links + links_in + links_out
+ )
+
+
+@dataclass
+class PrepareExpandMacroResult:
+ nodes: Sequence[Node]
+ links: Sequence[Link]
+
+
+def prepare_expand_macro(
+ parent: MetaNode, node: MetaNode) -> PrepareExpandMacroResult:
+ nodes = node.nodes()
+ links_in = parent.find_links(sink_node=node)
+ links_out = parent.find_links(source_node=node)
+ links_internal = [
+ link for link in node.links() if not (
+ isinstance(link.sink_node, OutputNode) or
+ isinstance(link.source_node, InputNode)
+ )
+ ]
+ links_in_new = []
+ links_out_new = []
+ # merge all X -> (Input_A -> Y ...) to X -> Y
+ for ilink1 in links_in:
+ inode = node.node_for_input_channel(ilink1.sink_channel)
+ for ilink2 in node.find_links(
+ source_node=inode, source_channel=inode.source_channel
+ ):
+ links_in_new.append(
+ Link(ilink1.source_node, ilink1.source_channel,
+ ilink2.sink_node, ilink2.sink_channel,
+ enabled=ilink1.enabled)
+ )
+
+ # merge all (.. X -> Output_A) -> Y ...) to X -> Y
+ for olink1 in links_out:
+ onode = node.node_for_output_channel(olink1.source_channel)
+ for olink2 in node.find_links(
+ sink_node=onode, sink_channel=onode.sink_channel):
+ links_out_new.append(
+ Link(olink2.source_node, olink2.source_channel,
+ olink1.sink_node, olink1.sink_channel,
+ enabled=olink1.enabled)
+ )
+ nodes = [node for node in nodes
+ if not isinstance(node, (InputNode, OutputNode))]
+ return PrepareExpandMacroResult(
+ nodes, links_in_new + links_internal + links_out_new
+ )
+
+
+@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())
+
+
+def nodes_bounding_box(nodes):
+ # type: (Sequence[Node]) -> QRectF
+ """Return bounding box containing all the node positions."""
+ positions = [n.position for n in nodes]
+ p1 = (min((x for x, _ in positions), default=0),
+ min((y for _, y in positions), default=0))
+ p2 = (max((x for x, _ in positions), default=0),
+ max((y for _, y in positions), default=0))
+ r = QRectF()
+ r.setTopLeft(QPointF(*p1))
+ r.setBottomRight(QPointF(*p2))
+ return r
diff --git a/orangecanvas/document/windowgroupsdialog.py b/orangecanvas/document/windowgroupsdialog.py
new file mode 100644
index 000000000..466f9a10e
--- /dev/null
+++ b/orangecanvas/document/windowgroupsdialog.py
@@ -0,0 +1,107 @@
+from typing import List
+
+from AnyQt.QtCore import Qt
+from AnyQt.QtWidgets import (
+ QDialog, QVBoxLayout, QFormLayout, QComboBox, QCheckBox,
+ QDialogButtonBox, QMessageBox
+)
+
+
+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.AdjustToMinimumContentsLengthWithIcon,
+ 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=self.tr("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(self.tr(
+ "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()
diff --git a/orangecanvas/gui/breadcrumbs.py b/orangecanvas/gui/breadcrumbs.py
new file mode 100644
index 000000000..5370087b7
--- /dev/null
+++ b/orangecanvas/gui/breadcrumbs.py
@@ -0,0 +1,174 @@
+from dataclasses import dataclass, field
+from typing import Sequence, List
+
+from AnyQt.QtCore import Qt, Signal, QSize, QRect, QEvent, QMargins, QPoint
+from AnyQt.QtGui import (
+ QPainter, QIcon, QPaintEvent, QPalette, QResizeEvent, QMouseEvent
+)
+from AnyQt.QtWidgets import (
+ QFrame, QSizePolicy, QStyleOption, QStyle, QHBoxLayout, QSpacerItem,
+
+)
+
+
+class Breadcrumbs(QFrame):
+ @dataclass
+ class Item:
+ text: str
+ rect: QRect = field(default_factory=QRect)
+
+ activated = Signal(int)
+
+ def __init__(self, *args, **kwargs) -> None:
+ sp = kwargs.pop("sizePolicy", None)
+ super().__init__(*args, **kwargs)
+ if sp is None:
+ sp = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
+ self.setSizePolicy(sp)
+ self.setAttribute(Qt.WA_WState_OwnSizePolicy, True)
+ self.__items: List[Breadcrumbs.Item] = []
+ self.__layout = QHBoxLayout()
+ self.__layout.setContentsMargins(0, 0, 0, 0)
+ self.__layout.setSpacing(0)
+ self.__separator_symbol = "â–¸"
+ self.__text_margins = QMargins(3, 0, 3, 0)
+ self.__pressed = -1
+
+ def setBreadcrumbs(self, items: Sequence[str]) -> None:
+ self.__items = [Breadcrumbs.Item(text, QIcon(), ) for text in items]
+ layout = self.__layout
+ for i in reversed(range(self.__layout.count())):
+ layout.takeAt(i)
+ for i in range(len(self.__items)):
+ layout.addSpacerItem(
+ QSpacerItem(0, 0, QSizePolicy.Preferred, QSizePolicy.Minimum)
+ )
+ layout.addSpacerItem(
+ QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum)
+ )
+ self.update()
+ self.updateGeometry()
+ self.__do_layout()
+
+ def breadcrumbs(self) -> Sequence[str]:
+ return [it.text for it in self.__items]
+
+ def resizeEvent(self, event: QResizeEvent) -> None:
+ super().resizeEvent(event)
+ self.__do_layout()
+
+ def changeEvent(self, event: QEvent) -> None:
+ if event.type() in (
+ QEvent.ContentsRectChange,
+ QEvent.FontChange,
+ QEvent.StyleChange,
+ ):
+ self.__do_layout()
+ super().changeEvent(event)
+
+ def sizeHint(self) -> QSize:
+ margins = self.contentsMargins()
+ option = QStyleOption()
+ option.initFrom(self)
+ fm = option.fontMetrics
+ width = 0
+ separator_width = fm.horizontalAdvance(self.__separator_symbol)
+ text_margins = self.__text_margins.left() + self.__text_margins.right()
+ N = len(self.__items)
+ for item in self.__items:
+ width += fm.horizontalAdvance(item.text) + text_margins
+ width += max(0, N - 1) * separator_width
+ height = fm.height()
+ sh = QSize(width + margins.left() + margins.right(),
+ height + margins.top() + margins.bottom())
+ return sh
+
+ def __do_layout(self):
+ layout = self.__layout
+ fm = self.fontMetrics()
+ height = fm.height()
+ N = len(self.__items)
+ separator_width = fm.horizontalAdvance(self.__separator_symbol)
+ margins = self.__text_margins.left() + self.__text_margins.right()
+ for i, item in enumerate(self.__items):
+ if N > 1 and (i == 0 or i == N - 1):
+ hpolicy = QSizePolicy.Minimum
+ else:
+ hpolicy = QSizePolicy.Preferred
+ spacing_adjust = separator_width if N > 1 and i != N - 1 else 0
+ spacer = layout.itemAt(i).spacerItem()
+ spacer.changeSize(
+ fm.horizontalAdvance(item.text, ) + margins + spacing_adjust,
+ height, hpolicy, QSizePolicy.Minimum
+ )
+ self.__layout.setGeometry(self.contentsRect())
+ self.__layout.activate()
+ for i, item in enumerate(self.__items):
+ item.rect = layout.itemAt(i).geometry()
+
+ def __componentAt(self, pos: QPoint) -> int:
+ for i, item in enumerate(self.__items):
+ if item.rect.contains(pos):
+ return i
+ return -1
+
+ def mousePressEvent(self, event: QMouseEvent) -> None:
+ if event.button() == Qt.LeftButton:
+ self.__pressed = self.__componentAt(event.pos())
+ self.update()
+
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
+ if event.button() != Qt.LeftButton:
+ event.ignore()
+ return
+ pressed = self.__componentAt(event.pos())
+ if pressed == self.__pressed and pressed >= 0:
+ self.activated.emit(pressed)
+ self.__pressed = -1
+ super().mouseReleaseEvent(event)
+
+ def paintEvent(self, event: QPaintEvent) -> None:
+ painter = QPainter(self)
+ painter.setClipRect(event.rect())
+ style = self.style()
+ option = QStyleOption()
+ option.initFrom(self)
+ fm = option.fontMetrics
+ separator_symbol = self.__separator_symbol
+ separator_width = fm.horizontalAdvance(separator_symbol)
+ N = len(self.__items)
+ margins = self.__text_margins
+ for i, item in enumerate(self.__items):
+ arrow_symbol = separator_symbol if i < N - 1 else None
+ is_last = i == N - 1
+ if is_last:
+ margins_ = margins
+ else:
+ margins_ = margins + QMargins(0, 0, separator_width, 0)
+ paint_item(
+ painter, item, option.palette, option.state, style, margins_,
+ arrow_symbol
+ )
+
+
+def paint_item(
+ painter: QPainter, item: Breadcrumbs.Item, palette: QPalette,
+ state: QStyle.State, style: QStyle, margins: QMargins, arrow=None
+) -> None:
+ rect = item.rect
+ text = item.text
+ align = Qt.AlignVCenter | Qt.AlignLeft
+ rect_text = rect.marginsRemoved(margins)
+ style.drawItemText(
+ painter, rect_text, align, palette, bool(state & QStyle.State_Enabled),
+ text
+ )
+ if arrow:
+ rect_arrow = QRect(rect)
+ rect_arrow.setLeft(rect.right() - margins.right() - 1)
+ painter.drawText(rect_arrow, Qt.AlignRight, arrow)
diff --git a/orangecanvas/gui/scene.py b/orangecanvas/gui/scene.py
new file mode 100644
index 000000000..f71fb6925
--- /dev/null
+++ b/orangecanvas/gui/scene.py
@@ -0,0 +1,313 @@
+from typing import Optional
+
+from AnyQt.QtCore import Qt, QObject, Signal
+from AnyQt.QtGui import QTransform, QKeyEvent
+from AnyQt.QtWidgets import (
+ QGraphicsScene, QGraphicsView, QGraphicsSceneHelpEvent, QToolTip,
+ QGraphicsSceneMouseEvent, QGraphicsSceneContextMenuEvent,
+ QGraphicsSceneDragDropEvent, QApplication
+)
+
+from orangecanvas.gui.quickhelp import QuickHelpTipEvent
+
+
+class UserInteraction(QObject):
+ """
+ Base class for user interaction handlers.
+
+ Parameters
+ ----------
+ parent : :class:`QObject`, optional
+ A parent QObject
+ deleteOnEnd : bool, optional
+ Should the UserInteraction be deleted when it finishes (``True``
+ by default).
+ """
+ # Cancel reason flags
+
+ #: No specified reason
+ NoReason = 0
+ #: User canceled the operation (e.g. pressing ESC)
+ UserCancelReason = 1
+ #: Another interaction was set
+ InteractionOverrideReason = 3
+ #: An internal error occurred
+ ErrorReason = 4
+ #: Other (unspecified) reason
+ OtherReason = 5
+
+ #: Emitted when the interaction is set on the scene.
+ started = Signal()
+
+ #: Emitted when the interaction finishes successfully.
+ finished = Signal()
+
+ #: Emitted when the interaction ends (canceled or finished)
+ ended = Signal()
+
+ #: Emitted when the interaction is canceled.
+ canceled = Signal(int)
+
+ def __init__(self, scene: 'GraphicsScene', parent: Optional[QObject] = None,
+ deleteOnEnd=True, **kwargs):
+ super().__init__(parent, **kwargs)
+ self.scene = scene
+ self.deleteOnEnd = deleteOnEnd
+ self.cancelOnEsc = False
+
+ self.__finished = False
+ self.__canceled = False
+ self.__cancelReason = self.NoReason
+
+ def start(self) -> None:
+ """
+ Start the interaction. This is called by the :class:`GraphicsScene`
+ when the interaction is installed.
+
+ .. note:: Must be called from subclass implementations.
+ """
+ self.started.emit()
+
+ def end(self) -> None:
+ """
+ Finish the interaction. Restore any leftover state in this method.
+
+ .. note:: This gets called from the default :func:`cancel`
+ implementation.
+ """
+ self.__finished = True
+
+ if self.scene.user_interaction_handler is self:
+ self.scene.set_user_interaction_handler(None)
+
+ if self.__canceled:
+ self.canceled.emit(self.__cancelReason)
+ else:
+ self.finished.emit()
+ self.ended.emit()
+
+ if self.deleteOnEnd:
+ self.deleteLater()
+
+ def cancel(self, reason=OtherReason) -> None:
+ """
+ Cancel the interaction with `reason`.
+ """
+ self.__canceled = True
+ self.__cancelReason = reason
+ self.end()
+
+ def isFinished(self) -> bool:
+ """
+ Is the interaction finished.
+ """
+ return self.__finished
+
+ def isCanceled(self) -> bool:
+ """
+ Was the interaction canceled.
+ """
+ return self.__canceled
+
+ def cancelReason(self) -> int:
+ """
+ Return the reason the interaction was canceled.
+ """
+ return self.__cancelReason
+
+ def postQuickTip(self, contents: str) -> None:
+ """
+ Post a QuickHelpTipEvent with rich text `contents` to the document
+ editor.
+ """
+ hevent = QuickHelpTipEvent("", contents)
+ QApplication.postEvent(self.document, hevent)
+
+ def clearQuickTip(self):
+ """Clear the quick tip help event."""
+ self.postQuickTip("")
+
+ def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.mousePressEvent`.
+ """
+ return False
+
+ def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> bool:
+ """
+ Handle a `GraphicsScene.mouseMoveEvent`.
+ """
+ return False
+
+ def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.mouseReleaseEvent`.
+ """
+ return False
+
+ def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.mouseDoubleClickEvent`.
+ """
+ return False
+
+ def keyPressEvent(self, event: QKeyEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.keyPressEvent`
+ """
+ if self.cancelOnEsc and event.key() == Qt.Key_Escape:
+ self.cancel(self.UserCancelReason)
+ return False
+
+ def keyReleaseEvent(self, event: QKeyEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.keyPressEvent`
+ """
+ return False
+
+ def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.contextMenuEvent`
+ """
+ return False
+
+ def dragEnterEvent(self, event: QGraphicsSceneDragDropEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.dragEnterEvent`
+
+ .. versionadded:: 0.1.20
+ """
+ return False
+
+ def dragMoveEvent(self, event: QGraphicsSceneDragDropEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.dragMoveEvent`
+
+ .. versionadded:: 0.1.20
+ """
+ return False
+
+ def dragLeaveEvent(self,event: QGraphicsSceneDragDropEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.dragLeaveEvent`
+
+ .. versionadded:: 0.1.20
+ """
+ return False
+
+ def dropEvent(self, event: QGraphicsSceneDragDropEvent) -> bool:
+ """
+ Handle a `QGraphicsScene.dropEvent`
+
+ .. versionadded:: 0.1.20
+ """
+ return False
+
+
+class GraphicsScene(QGraphicsScene):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.user_interaction_handler: Optional[UserInteraction] = None
+
+ def helpEvent(self, event: QGraphicsSceneHelpEvent) -> None:
+ """
+ Reimplemented.
+
+ Send the help event to every graphics item that is under the event's
+ scene position (default QGraphicsScene only dispatches help events to
+ `QGraphicsProxyWidget`s.
+ """
+ widget = event.widget()
+ if widget is not None and isinstance(widget.parentWidget(),
+ QGraphicsView):
+ view = widget.parentWidget()
+ deviceTransform = view.viewportTransform()
+ else:
+ deviceTransform = QTransform()
+ items = self.items(
+ event.scenePos(), Qt.IntersectsItemShape, Qt.DescendingOrder,
+ deviceTransform,
+ )
+ tooltiptext = None
+ event.setAccepted(False)
+ for item in items:
+ self.sendEvent(item, event)
+ if event.isAccepted():
+ return
+ elif item.toolTip():
+ tooltiptext = item.toolTip()
+ break
+ QToolTip.showText(event.screenPos(), tooltiptext, event.widget())
+
+ def mousePressEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.mousePressEvent(event):
+ return
+ return super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.mouseMoveEvent(event):
+ return
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.mouseReleaseEvent(event):
+ return
+ super().mouseReleaseEvent(event)
+
+ def mouseDoubleClickEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.mouseDoubleClickEvent(event):
+ return
+ super().mouseDoubleClickEvent(event)
+
+ def keyPressEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.keyPressEvent(event):
+ return
+ super().keyPressEvent(event)
+
+ def keyReleaseEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.keyReleaseEvent(event):
+ return
+ super().keyReleaseEvent(event)
+
+ def contextMenuEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.contextMenuEvent(event):
+ return
+ super().contextMenuEvent(event)
+
+ def dragEnterEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.dragEnterEvent(event):
+ return
+ super().dragEnterEvent(event)
+
+ def dragMoveEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.dragMoveEvent(event):
+ return
+ super().dragMoveEvent(event)
+
+ def dragLeaveEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.dragLeaveEvent(event):
+ return
+ super().dragLeaveEvent(event)
+
+ def dropEvent(self, event):
+ if self.user_interaction_handler and \
+ self.user_interaction_handler.dropEvent(event):
+ return
+ super().dropEvent(event)
+
+ def set_user_interaction_handler(self, handler):
+ # type: (UserInteraction) -> None
+ if self.user_interaction_handler and \
+ not self.user_interaction_handler.isFinished():
+ self.user_interaction_handler.cancel()
+ self.user_interaction_handler = handler
diff --git a/orangecanvas/gui/tests/test_breadcrumbs.py b/orangecanvas/gui/tests/test_breadcrumbs.py
new file mode 100644
index 000000000..de0c0830a
--- /dev/null
+++ b/orangecanvas/gui/tests/test_breadcrumbs.py
@@ -0,0 +1,26 @@
+from AnyQt.QtCore import Qt
+from AnyQt.QtTest import QTest, QSignalSpy
+
+from ..breadcrumbs import Breadcrumbs
+from ..test import QAppTestCase
+
+
+class TestBreadcrumbs(QAppTestCase):
+ def test(self):
+ w = Breadcrumbs()
+ w.grab()
+ w.setBreadcrumbs([])
+ w.setBreadcrumbs(["A"])
+ w.setBreadcrumbs([])
+ w.setBreadcrumbs(["A" * 10, "B" * 10, "c" * 10])
+ w.adjustSize()
+ w.grab()
+
+ def test_activated(self):
+ w = Breadcrumbs()
+ w.setBreadcrumbs(["AA" * 5, "BB" * 5, "CC" * 5])
+ w.adjustSize()
+ rect = w.rect()
+ spy = QSignalSpy(w.activated)
+ QTest.mouseClick(w, Qt.LeftButton, Qt.NoModifier, rect.center())
+ self.assertSequenceEqual(list(spy), [[1]])
diff --git a/orangecanvas/icons/MetaNode.svg b/orangecanvas/icons/MetaNode.svg
new file mode 100644
index 000000000..cacbd4cc5
--- /dev/null
+++ b/orangecanvas/icons/MetaNode.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/orangecanvas/icons/input.svg b/orangecanvas/icons/input.svg
new file mode 100644
index 000000000..d77653d8e
--- /dev/null
+++ b/orangecanvas/icons/input.svg
@@ -0,0 +1,4 @@
+
diff --git a/orangecanvas/icons/output.svg b/orangecanvas/icons/output.svg
new file mode 100644
index 000000000..a4e13ce75
--- /dev/null
+++ b/orangecanvas/icons/output.svg
@@ -0,0 +1,4 @@
+
diff --git a/orangecanvas/preview/scanner.py b/orangecanvas/preview/scanner.py
index 47c115b38..07d65888a 100644
--- a/orangecanvas/preview/scanner.py
+++ b/orangecanvas/preview/scanner.py
@@ -9,6 +9,7 @@
from xml.sax import make_parser, handler, saxutils, SAXParseException
from typing import BinaryIO, Tuple, List
+from ..canvas.utils import grab_svg
from ..scheme.readwrite import scheme_load
if typing.TYPE_CHECKING:
@@ -126,7 +127,6 @@ def scheme_svg_thumbnail(scheme_file):
"""
from ..scheme import Scheme
from ..canvas import scene
- from ..registry import global_registry
scheme = Scheme()
scheme.set_loop_flags(scheme.AllowLoops | scheme.AllowSelfLoops)
@@ -139,7 +139,6 @@ def scheme_svg_thumbnail(scheme_file):
tmp_scene = scene.CanvasScene()
tmp_scene.set_channel_names_visible(False)
- tmp_scene.set_registry(global_registry())
tmp_scene.set_node_animation_enabled(False)
tmp_scene.set_scheme(scheme)
@@ -148,7 +147,7 @@ def scheme_svg_thumbnail(scheme_file):
# Last added node is auto-selected. Need to clear.
tmp_scene.clearSelection()
- svg = scene.grab_svg(tmp_scene)
+ svg = grab_svg(tmp_scene)
tmp_scene.clear()
tmp_scene.deleteLater()
return svg
diff --git a/orangecanvas/registry/base.py b/orangecanvas/registry/base.py
index a877391e2..50ec2b886 100644
--- a/orangecanvas/registry/base.py
+++ b/orangecanvas/registry/base.py
@@ -4,6 +4,7 @@
===============
"""
+import copy
import logging
import bisect
@@ -22,7 +23,7 @@
log = logging.getLogger(__name__)
# Registry hex version
-VERSION_HEX = 0x000106
+VERSION_HEX = 0x000107
class WidgetRegistry(object):
@@ -219,6 +220,11 @@ def _insert_widget(self, category, desc):
assert isinstance(category, description.CategoryDescription)
_, widgets = self._categories_dict[category.name]
+ if desc.background is None:
+ desc.background = category.background
+ if desc.category is None:
+ desc.category = category.name
+
priority = desc.priority
priorities = [w.priority for w in widgets]
insertion_i = bisect.bisect_right(priorities, priority)
diff --git a/orangecanvas/registry/description.py b/orangecanvas/registry/description.py
index d694a6f24..ed213e84d 100644
--- a/orangecanvas/registry/description.py
+++ b/orangecanvas/registry/description.py
@@ -119,7 +119,7 @@ class InputSignal(object):
default = None # type: bool
explicit = None # type: bool
- def __init__(self, name, type, handler, flags=Single + NonDefault,
+ def __init__(self, name, type, handler="", flags=Single + NonDefault,
id=None, doc=None, replaces=()):
# type: (str, TypeSpec, str, int, Optional[str], Optional[str], Iterable[str]) -> None
self.name = name
@@ -227,6 +227,7 @@ def __init__(self, name, type, flags=NonDefault,
self.default = bool(flags & Default)
self.explicit = bool(flags & Explicit)
self.dynamic = bool(flags & Dynamic)
+ self.single = bool(flags & Single)
self.flags = flags
@property
diff --git a/orangecanvas/registry/qt.py b/orangecanvas/registry/qt.py
index 200710711..41000fab5 100644
--- a/orangecanvas/registry/qt.py
+++ b/orangecanvas/registry/qt.py
@@ -209,7 +209,7 @@ def _insert_widget(self, category, desc):
insertion_i = bisect.bisect_right(priorities, desc.priority)
WidgetRegistry._insert_widget(self, category, desc)
-
+ desc = self.widget(desc.qualified_name)
cat_item = self.__item_model.item(cat_i)
widget_item = self._widget_desc_to_std_item(desc, category)
diff --git a/orangecanvas/scheme/__init__.py b/orangecanvas/scheme/__init__.py
index 30504a93a..2ea15940c 100644
--- a/orangecanvas/scheme/__init__.py
+++ b/orangecanvas/scheme/__init__.py
@@ -6,33 +6,40 @@
The scheme package implements and defines the underlying workflow model.
The :class:`.Scheme` class represents the workflow and is composed of a set
-of :class:`.SchemeNode` connected with :class:`.SchemeLink`, defining an
+of :class:`.Node`\\s connected with :class:`.Link`\\s, defining an
directed acyclic graph (DAG). Additionally instances of
-:class:`.SchemeArrowAnnotation` or :class:`.SchemeTextAnnotation` can be
-inserted into the scheme.
-
+:class:`~.annotations.ArrowAnnotation` or :class:`~.annotations.TextAnnotation`
+can be inserted into the scheme.
"""
-from .node import SchemeNode
-from .link import SchemeLink, compatible_channels, can_connect, possible_links
+from .node import Node, SchemeNode
+from .metanode import MetaNode, InputNode, OutputNode
+from .link import Link, compatible_channels, can_connect, possible_links
from .scheme import Scheme
-
-from .annotations import (
- BaseSchemeAnnotation, SchemeArrowAnnotation, SchemeTextAnnotation
-)
+from .annotations import Annotation, ArrowAnnotation, TextAnnotation
+from ..registry import InputSignal, OutputSignal
from .errors import *
from .events import *
-#: Alias for SchemeNode
-Node = SchemeNode
+__all__ = [
+ "Node", "InputNode", "OutputNode", "SchemeNode", "MetaNode", "Link",
+ "Workflow", "Scheme", "Annotation", "ArrowAnnotation", "TextAnnotation",
+ "InputSignal", "OutputSignal", "compatible_channels", "can_connect",
+ "possible_links",
+ # from .events import *
+ "WorkflowEvent", "NodeEvent", "NodeInputChannelEvent",
+ "NodeOutputChannelEvent", "LinkEvent", "AnnotationEvent",
+ "WorkflowEnvChanged"
+]
+
#: Alias for SchemeLink
-Link = SchemeLink
+SchemeLink = Link
#: Alias for Scheme
Workflow = Scheme
#: Alias for BaseSchemeAnnotation
-Annotation = BaseSchemeAnnotation
+BaseSchemeAnnotation = Annotation
#: Alias for SchemeArrowAnnotation
-Arrow = SchemeArrowAnnotation
+SchemeArrowAnnotation = Arrow = ArrowAnnotation
#: Alias for SchemeTextAnnotation
-Text = SchemeTextAnnotation
+SchemeTextAnnotation = Text = TextAnnotation
diff --git a/orangecanvas/scheme/annotations.py b/orangecanvas/scheme/annotations.py
index 32bdd2b13..3fe4ad67a 100644
--- a/orangecanvas/scheme/annotations.py
+++ b/orangecanvas/scheme/annotations.py
@@ -10,12 +10,13 @@
from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property
from ..utils import check_type
+from .element import Element
Pos = Tuple[float, float]
Rect = Tuple[float, float, float, float]
-class BaseSchemeAnnotation(QObject):
+class Annotation(Element):
"""
Base class for scheme annotations.
"""
@@ -23,7 +24,7 @@ class BaseSchemeAnnotation(QObject):
geometry_changed = Signal()
-class SchemeArrowAnnotation(BaseSchemeAnnotation):
+class ArrowAnnotation(Annotation):
"""
An arrow annotation in the scheme.
"""
@@ -121,7 +122,7 @@ def __setstate__(self, state):
self.__init__(*state)
-class SchemeTextAnnotation(BaseSchemeAnnotation):
+class TextAnnotation(Annotation):
"""
Text annotation in the scheme.
"""
@@ -280,3 +281,9 @@ def __getstate__(self):
def __setstate__(self, state):
self.__init__(*state)
+
+
+#: Aliases for back-compatibility
+BaseSchemeAnnotation = Annotation
+SchemeArrowAnnotation = ArrowAnnotation
+SchemeTextAnnotation = TextAnnotation
diff --git a/orangecanvas/scheme/element.py b/orangecanvas/scheme/element.py
new file mode 100644
index 000000000..5ea53764e
--- /dev/null
+++ b/orangecanvas/scheme/element.py
@@ -0,0 +1,31 @@
+from typing import TYPE_CHECKING, Optional
+
+from AnyQt.QtCore import QObject
+
+if TYPE_CHECKING:
+ from . import MetaNode, Workflow
+
+
+class Element(QObject):
+ """
+ Base class for workflow elements.
+ """
+ __parent_node: Optional['MetaNode'] = None
+
+ def _set_parent_node(self, node: Optional['MetaNode']) -> None:
+ """Internal. Set the parent node."""
+ self.__parent_node = node
+
+ def parent_node(self) -> Optional['MetaNode']:
+ """Return the parent workflow node."""
+ return self.__parent_node
+
+ __workflow: Optional['Workflow'] = None
+
+ def _set_workflow(self, workflow: Optional['Workflow']):
+ """Internal. Set the parent workflow."""
+ self.__workflow = workflow
+
+ def workflow(self) -> Optional['Workflow']:
+ """Return the parent workflow."""
+ return self.__workflow
diff --git a/orangecanvas/scheme/events.py b/orangecanvas/scheme/events.py
index 7194fcea5..41d31c628 100644
--- a/orangecanvas/scheme/events.py
+++ b/orangecanvas/scheme/events.py
@@ -8,15 +8,18 @@
"""
import typing
-from typing import Any, Union, cast
+from typing import Any, Union, Optional, cast
from AnyQt.QtCore import QEvent
+from orangecanvas.registry import InputSignal, OutputSignal
+
if typing.TYPE_CHECKING:
- from orangecanvas.scheme import SchemeLink, SchemeNode, BaseSchemeAnnotation
+ from orangecanvas.scheme import Node, MetaNode, Link, Annotation
__all__ = [
- "WorkflowEvent", "NodeEvent", "LinkEvent", "AnnotationEvent",
+ "WorkflowEvent", "NodeEvent", "NodeInputChannelEvent",
+ "NodeOutputChannelEvent", "LinkEvent", "AnnotationEvent",
"WorkflowEnvChanged"
]
@@ -48,6 +51,11 @@ class WorkflowEvent(QEvent):
#: An output Link has been removed from a node (:class:`LinkEvent`)
OutputLinkRemoved = QEvent.Type(QEvent.registerEventType())
+ InputChannelAdded = QEvent.registerEventType()
+ InputChannelRemoved = QEvent.registerEventType()
+ OutputChannelAdded = QEvent.registerEventType()
+ OutputChannelRemoved = QEvent.registerEventType()
+
#: Node's (runtime) state has changed (:class:`NodeEvent`)
NodeStateChange = QEvent.Type(QEvent.registerEventType())
@@ -107,25 +115,31 @@ class NodeEvent(WorkflowEvent):
* :data:`WorkflowEvent.NodeActivateRequest`
* :data:`WorkflowEvent.ActivateParentRequest`
* :data:`WorkflowEvent.OutputLinkRemoved`
+ * :data:`WorkflowEvent.InputChannelAdded`
+ * :data:`WorkflowEvent.InputChannelRemoved`
+ * :data:`WorkflowEvent.OutputChannelAdded`
+ * :data:`WorkflowEvent.OutputChannelRemoved`
Parameters
----------
etype: QEvent.Type
- node: SchemeNode
+ node: Node
pos: int
+ parent: Optional[MetaNode]
"""
- def __init__(self, etype, node, pos=-1):
- # type: (_EType, SchemeNode, int) -> None
+ def __init__(self, etype, node, pos=-1, parent=None):
+ # type: (_EType, Node, int, Optional[MetaNode]) -> None
super().__init__(etype)
self.__node = node
self.__pos = pos
+ self.__parent = parent
def node(self):
- # type: () -> SchemeNode
+ # type: () -> Node
"""
Return
------
- node : SchemeNode
+ node : Node
The node instance.
"""
return self.__node
@@ -139,6 +153,41 @@ def pos(self) -> int:
"""
return self.__pos
+ def parent(self):
+ # type: () -> Optional['MetaNode']
+ """
+ The parent meta node instance
+ """
+ return self.__parent
+
+
+class NodeInputChannelEvent(NodeEvent):
+ def __init__(self, etype, node, channel, pos=-1):
+ # type: (QEvent.Type, Node, InputSignal, int) -> None
+ super().__init__(etype, node)
+ self.__channel = channel
+ self.__pos = pos
+
+ def channel(self) -> InputSignal:
+ return self.__channel
+
+ def pos(self):
+ return self.__pos
+
+
+class NodeOutputChannelEvent(NodeEvent):
+ def __init__(self, etype, node, channel, pos=-1):
+ # type: (QEvent.Type, Node, OutputSignal, int) -> None
+ super().__init__(etype, node)
+ self.__channel = channel
+ self.__pos = pos
+
+ def channel(self) -> OutputSignal:
+ return self.__channel
+
+ def pos(self):
+ return self.__pos
+
class LinkEvent(WorkflowEvent):
"""
@@ -158,23 +207,25 @@ class LinkEvent(WorkflowEvent):
Parameters
----------
etype: QEvent.Type
- link: SchemeLink
+ link: Link
The link subject to change
pos: int
The link position index.
+ parent: Optional[MetaNode]
"""
- def __init__(self, etype, link, pos=-1):
- # type: (_EType, SchemeLink, int) -> None
+ def __init__(self, etype, link, pos=-1, parent=None):
+ # type: (_EType, Link, int, Optional[MetaNode]) -> None
super().__init__(etype)
self.__link = link
self.__pos = pos
+ self.__parent = parent
def link(self):
- # type: () -> SchemeLink
+ # type: () -> Link
"""
Return
------
- link : SchemeLink
+ link : Link
The link instance.
"""
return self.__link
@@ -196,6 +247,9 @@ def pos(self) -> int:
"""
return self.__pos
+ def parent(self) -> Optional['MetaNode']:
+ return self.__parent
+
class AnnotationEvent(WorkflowEvent):
"""
@@ -209,22 +263,24 @@ class AnnotationEvent(WorkflowEvent):
Parameters
----------
etype: QEvent.Type
- annotation: BaseSchemeAnnotation
+ annotation: Annotation
The annotation that is a subject of change.
pos: int
+ parent: Optional[MetaNode]
"""
- def __init__(self, etype, annotation, pos=-1):
- # type: (_EType, BaseSchemeAnnotation, int) -> None
+ def __init__(self, etype, annotation, pos=-1, parent=None):
+ # type: (_EType, Annotation, int, Optional[MetaNode]) -> None
super().__init__(etype)
self.__annotation = annotation
self.__pos = pos
+ self.__parent = parent
def annotation(self):
- # type: () -> BaseSchemeAnnotation
+ # type: () -> Annotation
"""
Return
------
- annotation : BaseSchemeAnnotation
+ annotation : Annotation
The annotation instance.
"""
return self.__annotation
@@ -237,6 +293,9 @@ def pos(self) -> int:
"""
return self.__pos
+ def parent(self) -> Optional['MetaNode']:
+ return self.__parent
+
class WorkflowEnvChanged(WorkflowEvent):
"""
diff --git a/orangecanvas/scheme/link.py b/orangecanvas/scheme/link.py
index 49605ba2b..9cbc48285 100644
--- a/orangecanvas/scheme/link.py
+++ b/orangecanvas/scheme/link.py
@@ -17,10 +17,11 @@
from ..utils import type_lookup
from .errors import IncompatibleChannelTypeError
from .events import LinkEvent
+from .element import Element
if typing.TYPE_CHECKING:
from ..registry import OutputSignal as Output, InputSignal as Input
- from . import SchemeNode as Node
+ from . import Node
def resolve_types(types):
@@ -158,18 +159,18 @@ def _get_first_type(arg, newname):
raise TypeError("{!r} does not resolve to a type")
-class SchemeLink(QObject):
+class Link(Element):
"""
- A instantiation of a link between two :class:`.SchemeNode` instances
+ A instantiation of a link between two :class:`.Node` instances
in a :class:`.Scheme`.
Parameters
----------
- source_node : :class:`.SchemeNode`
+ source_node : :class:`.Node`
Source node.
source_channel : :class:`OutputSignal`
The source widget's signal.
- sink_node : :class:`.SchemeNode`
+ sink_node : :class:`.Node`
The sink node.
sink_channel : :class:`InputSignal`
The sink widget's input signal.
@@ -217,14 +218,14 @@ class State(enum.IntEnum):
def __init__(self, source_node, source_channel,
sink_node, sink_channel,
enabled=True, properties=None, parent=None):
- # type: (Node, Output, Node, Input, bool, dict, QObject) -> None
+ # type: (Node, Union[Output, str], Node, Union[Input, str], bool, dict, QObject) -> None
super().__init__(parent)
self.source_node = source_node
if isinstance(source_channel, str):
source_channel = source_node.output_channel(source_channel)
elif source_channel not in source_node.output_channels():
- raise ValueError("%r not in in nodes output channels." \
+ raise ValueError("%r not in in node's output channels."
% source_channel)
self.source_channel = source_channel
@@ -234,7 +235,7 @@ def __init__(self, source_node, source_channel,
if isinstance(sink_channel, str):
sink_channel = sink_node.input_channel(sink_channel)
elif sink_channel not in sink_node.input_channels():
- raise ValueError("%r not in in nodes input channels." \
+ raise ValueError("%r not in in node's input channels."
% source_channel)
self.sink_channel = sink_channel
@@ -247,7 +248,7 @@ def __init__(self, source_node, source_channel,
self.__enabled = enabled
self.__dynamic_enabled = False
- self.__state = SchemeLink.NoState # type: Union[SchemeLink.State, int]
+ self.__state = Link.NoState # type: Union[Link.State, int]
self.__tool_tip = ""
self.properties = properties or {}
@@ -356,7 +357,7 @@ def set_runtime_state(self, state):
Parameters
----------
- state : SchemeLink.State
+ state : Link.State
"""
if self.__state != state:
self.__state = state
@@ -371,7 +372,7 @@ def runtime_state(self):
"""
Returns
-------
- state : SchemeLink.State
+ state : Link.State
"""
return self.__state
@@ -382,7 +383,7 @@ def set_runtime_state_flag(self, flag, on):
Parameters
----------
- flag: SchemeLink.State
+ flag: Link.State
on: bool
"""
if on:
@@ -398,7 +399,7 @@ def test_runtime_state(self, flag):
Parameters
----------
- flag: SchemeLink.State
+ flag: Link.State
State flag to test
Returns
@@ -450,3 +451,6 @@ def __setstate__(self, state):
# correct sink channel
mutable_state[3] = state[2].input_channel(state[3])
self.__init__(*mutable_state)
+
+
+SchemeLink = Link
diff --git a/orangecanvas/scheme/metanode.py b/orangecanvas/scheme/metanode.py
new file mode 100644
index 000000000..e93ad1b1a
--- /dev/null
+++ b/orangecanvas/scheme/metanode.py
@@ -0,0 +1,674 @@
+import pkgutil
+from itertools import chain
+from typing import TYPE_CHECKING, Optional, List, Iterable, TypeVar, Sequence
+
+from AnyQt.QtCore import Signal, QCoreApplication
+from AnyQt.QtGui import QIcon
+
+from ..gui.svgiconengine import SvgIconEngine
+from ..registry import InputSignal, OutputSignal
+from ..utils import findf, unique, apply_all
+from ..utils.graph import traverse_bf
+from .node import Node
+from .link import Link, compatible_channels
+from .annotations import Annotation
+from .events import NodeEvent, LinkEvent, AnnotationEvent
+from .errors import (
+ DuplicatedLinkError, SinkChannelError, SchemeCycleError,
+ IncompatibleChannelTypeError
+)
+
+if TYPE_CHECKING:
+ from . import Workflow
+
+
+class MetaNode(Node):
+ """
+ A representation of a meta workflow node (a grouping of nodes defining
+ a subgraph group within a workflow).
+
+ Inputs and outputs to the meta node are defined by adding `InputNode` and
+ `OutputNode` instances.
+ """
+ #: Emitted when a new subnode is inserted at index
+ node_inserted = Signal(int, Node)
+ #: Emitted when a subnode is removed
+ node_removed = Signal(Node)
+
+ #: Emitted when a link is inserted at index
+ link_inserted = Signal(int, Link)
+ #: Emitted when a link is removed
+ link_removed = Signal(Link)
+
+ #: Emitted when a annotation is inserted at index
+ annotation_inserted = Signal(int, Annotation)
+ #: Emitted when a annotation is removed
+ annotation_removed = Signal(Annotation)
+
+ def __init__(self, title="", position=(0, 0), **kwargs):
+ super().__init__(title, position, **kwargs)
+ self.__nodes: List[Node] = []
+ self.__links: List[Link] = []
+ self.__annotations: List[Annotation] = []
+
+ def nodes(self) -> List[Node]:
+ """Return a list of subnodes."""
+ return list(self.__nodes)
+
+ def all_nodes(self) -> List[Node]:
+ """Return a list of all subnodes including all subnodes of subnodes.
+
+ I.e. recursive `nodes()`
+ """
+ return list(all_nodes_recursive(self))
+
+ def links(self) -> List[Link]:
+ """Return a list of all links."""
+ return list(self.__links)
+
+ def all_links(self) -> List[Link]:
+ """
+ Return a list of all links including all links in subnodes.
+
+ I.e. recursive `links()`
+ """
+ return list(all_links_recursive(self))
+
+ def annotations(self) -> List[Annotation]:
+ """Return a list of all annotations."""
+ return list(self.__annotations)
+
+ def all_annotations(self) -> List[Annotation]:
+ """
+ Return a list of all annotations including all annotations in subnodes.
+
+ I.e. recursive `annotations()`
+ """
+ return list(all_annotations_recursive(self))
+
+ def input_nodes(self) -> List['InputNode']:
+ """Return a list of all `InputNode`\\s."""
+ return [node for node in self.__nodes if isinstance(node, InputNode)]
+
+ def output_nodes(self) -> List['OutputNode']:
+ """Return a list of all `OutputNode`\\s."""
+ return [node for node in self.__nodes if isinstance(node, OutputNode)]
+
+ def create_input_node(self, input: InputSignal):
+ """Create and add a new :class:`InputNode` instance for `input`"""
+ output = OutputSignal(
+ input.name, input.types, id=input.id, flags=input.flags
+ )
+ node = InputNode(input, output)
+ node.set_title(input.name)
+ self.add_node(node)
+ return node
+
+ def create_output_node(self, output: OutputSignal):
+ """Create and add a new :class:`OutputNode` instance for `output`"""
+ input = InputSignal(
+ output.name, output.types, "-", flags=output.flags
+ )
+ node = OutputNode(input, output)
+ node.set_title(output.name)
+ self.add_node(node)
+ return node
+
+ def remove_input_channel(self, index: int) -> InputSignal:
+ """Reimplemented"""
+ parent = self.parent_node()
+ if parent is not None:
+ chn = self.input_channels()[index]
+ in_node = self.node_for_input_channel(chn)
+ if in_node is not None:
+ links = parent.find_links(
+ sink_node=in_node, sink_channel=chn
+ )
+ for link in links:
+ parent.remove_link(link)
+ return super().remove_input_channel(index)
+
+ def remove_output_channel(self, index: int) -> OutputSignal:
+ """Reimplemented"""
+ parent = self.parent_node()
+ if parent is not None:
+ chn = self.output_channels()[index]
+ out_node = self.node_for_output_channel(chn)
+ if out_node is not None:
+ links = parent.find_links(
+ source_node=out_node, source_channel=chn
+ )
+ for link in links:
+ parent.remove_link(link)
+ return super().remove_output_channel(index)
+
+ def add_node(self, node: Node):
+ """
+ Add the `node` to this meta node.
+
+ An error is raised if the `node` is already part if a workflow.
+ """
+ self.insert_node(len(self.__nodes), node)
+
+ def insert_node(self, index: int, node: Node):
+ """
+ Insert the `node` into `self.nodes()` at the specified position `index`.
+
+ An error is raised if the `node` is already part if a workflow.
+ """
+ if node.workflow() is not None:
+ raise RuntimeError("'node' is already in a workflow")
+ workflow = self.workflow()
+ self.__nodes.insert(index, node)
+ if isinstance(node, InputNode):
+ self.add_input_channel(node.sink_channel)
+ elif isinstance(node, OutputNode):
+ self.add_output_channel(node.source_channel)
+ node._set_parent_node(self)
+ node._set_workflow(workflow)
+ ev = NodeEvent(NodeEvent.NodeAdded, node, index, self)
+ QCoreApplication.sendEvent(self, ev)
+ if workflow is not None:
+ QCoreApplication.sendEvent(workflow, ev)
+ workflow.node_added.emit(node, self)
+ workflow.node_inserted.emit(index, node, self)
+ self.node_inserted.emit(index, node)
+
+ def remove_node(self, node: Node) -> None:
+ """Remove the `node` from this meta node."""
+ workflow = self.workflow()
+ if isinstance(node, InputNode):
+ self.remove_input_channel(self.input_channels().index(node.sink_channel))
+ elif isinstance(node, OutputNode):
+ self.remove_output_channel(self.output_channels().index(node.source_channel))
+ self.__remove_node_links(node)
+ index = self.__nodes.index(node)
+ self.__nodes.pop(index)
+
+ node._set_parent_node(None)
+ node._set_workflow(None)
+ ev = NodeEvent(NodeEvent.NodeRemoved, node, index, self)
+ QCoreApplication.sendEvent(self, ev)
+ if workflow is not None:
+ QCoreApplication.sendEvent(workflow, ev)
+ workflow.node_removed.emit(node, self)
+ self.node_removed.emit(node)
+
+ def __remove_node_links(self, node: Node):
+ links = self.__links
+ links_out = [link for link in links if link.source_node is node]
+ links_in = [link for link in links if link.sink_node is node]
+ for link in chain(links_out, links_in):
+ self.remove_link(link)
+
+ def input_links(self, node) -> List[Link]:
+ """Return all input links to this meta node."""
+ return self.find_links(sink_node=node)
+
+ def output_links(self, node) -> List[Link]:
+ """Return all output links from this meta node."""
+ return self.find_links(source_node=node)
+
+ def find_links(
+ self, source_node: Optional[Node] = None,
+ source_channel: Optional[OutputSignal] = None,
+ sink_node: Optional[Node] = None,
+ sink_channel: Optional[InputSignal] = None
+ ) -> List[Link]:
+ """Find and return links based on matching criteria."""
+ return find_links(
+ self.__links, source_node, source_channel, sink_node, sink_channel
+ )
+
+ def add_link(self, link: Link):
+ """
+ Add `link` to this meta node.
+
+ `link.source_node` and `link.sink_node` must already be added.
+ """
+ self.insert_link(len(self.__links), link)
+
+ def insert_link(self, index: int, link: Link):
+ """
+ Insert `link` into `self.links()` at the specified position `index`.
+ """
+ if link.workflow() is not None:
+ raise RuntimeError("'link' is already in a workflow")
+ if link.source_node not in self.__nodes:
+ raise RuntimeError("'link.source_node' is not in self.nodes()")
+ if link.sink_node not in self.__nodes:
+ raise RuntimeError("'link.sink_node' is not in self.nodes()")
+ workflow = self.workflow()
+ self.__check_connect(link)
+ self.__links.insert(index, link)
+ link._set_workflow(workflow)
+ link._set_parent_node(self)
+ source_index, _ = findf(
+ enumerate(self.find_links(source_node=link.source_node)),
+ lambda t: t[1] == link,
+ default=(-1, None)
+ )
+ sink_index, _ = findf(
+ enumerate(self.find_links(sink_node=link.sink_node)),
+ lambda t: t[1] == link,
+ default=(-1, None)
+ )
+ assert sink_index != -1 and source_index != -1
+ QCoreApplication.sendEvent(
+ link.source_node,
+ LinkEvent(LinkEvent.OutputLinkAdded, link, source_index, self)
+ )
+ QCoreApplication.sendEvent(
+ link.sink_node,
+ LinkEvent(LinkEvent.InputLinkAdded, link, sink_index, self)
+ )
+ ev = LinkEvent(LinkEvent.LinkAdded, link, index, self)
+ QCoreApplication.sendEvent(self, ev)
+ if workflow is not None:
+ QCoreApplication.sendEvent(workflow, ev)
+ workflow.link_inserted.emit(index, link, self)
+ workflow.link_added.emit(link, self)
+ self.link_inserted.emit(index, link)
+
+ def remove_link(self, link: Link) -> None:
+ """
+ Remove the `link` from this meta node.
+ """
+ assert link in self.__links, "Link is not in the scheme."
+ source_index, _ = findf(
+ enumerate(self.find_links(source_node=link.source_node)),
+ lambda t: t[1] == link,
+ default=(-1, None)
+ )
+ sink_index, _ = findf(
+ enumerate(self.find_links(sink_node=link.sink_node)),
+ lambda t: t[1] == link,
+ default=(-1, None)
+ )
+ assert sink_index != -1 and source_index != -1
+ index = self.__links.index(link)
+ self.__links.pop(index)
+ link._set_parent_node(None)
+ link._set_workflow(None)
+ QCoreApplication.sendEvent(
+ link.sink_node,
+ LinkEvent(LinkEvent.InputLinkRemoved, link, sink_index, self)
+ )
+ QCoreApplication.sendEvent(
+ link.source_node,
+ LinkEvent(LinkEvent.OutputLinkRemoved, link, source_index, self)
+ )
+ ev = LinkEvent(LinkEvent.LinkRemoved, link, index, self)
+ QCoreApplication.sendEvent(self, ev)
+ workflow = self.workflow()
+ if workflow is not None:
+ QCoreApplication.sendEvent(workflow, ev)
+ workflow.link_removed.emit(link, self)
+ self.link_removed.emit(link)
+
+ def __check_connect(self, link: Link):
+ w = self.workflow()
+ if w is not None:
+ return w.check_connect(link)
+ else:
+ return check_connect(self.__links, link)
+
+ def add_annotation(self, annotation: Annotation):
+ """Add the `annotation` to this meta node."""
+ self.insert_annotation(len(self.__annotations), annotation)
+
+ def insert_annotation(self, index: int, annotation: Annotation) -> None:
+ """
+ Insert `annotation` into `self.annotations()` at the specified
+ position `index`.
+ """
+ if annotation.workflow() is not None:
+ raise RuntimeError("'annotation' is already in a workflow")
+ w = self.workflow()
+ self.__annotations.insert(index, annotation)
+ annotation._set_parent_node(self)
+ annotation._set_workflow(w)
+ ev = AnnotationEvent(AnnotationEvent.AnnotationAdded,
+ annotation, index, self)
+ QCoreApplication.sendEvent(self, ev)
+ if w is not None:
+ QCoreApplication.sendEvent(w, ev)
+ w.annotation_inserted.emit(index, annotation, self)
+ w.annotation_added.emit(annotation, self)
+ self.annotation_inserted.emit(index, annotation)
+
+ def remove_annotation(self, annotation):
+ """Remove the `annotation` from this meta node."""
+ index = self.__annotations.index(annotation)
+ self.__annotations.pop(index)
+ annotation._set_workflow(None)
+ annotation._set_parent_node(None)
+ ev = AnnotationEvent(AnnotationEvent.AnnotationRemoved,
+ annotation, index, self)
+ QCoreApplication.sendEvent(self, ev)
+ w = self.workflow()
+ if w is not None:
+ QCoreApplication.sendEvent(w, ev)
+ w.annotation_removed.emit(annotation, self)
+ self.annotation_removed.emit(annotation)
+
+ def clear(self):
+ """
+ Remove all subnodes, links and annotations from this node.
+ """
+ def is_terminal(node):
+ # type: (Node) -> bool
+ return not bool(self.find_links(source_node=node))
+
+ while self.__nodes:
+ terminal_nodes = list(filter(is_terminal, self.__nodes))
+ for node in terminal_nodes:
+ if isinstance(node, MetaNode):
+ node.clear()
+ self.remove_node(node)
+
+ for annotation in list(self.__annotations):
+ self.remove_annotation(annotation)
+
+ assert not (self.__nodes or self.__links or self.__annotations)
+
+ def node_for_input_channel(self, channel: InputSignal) -> 'InputNode':
+ """
+ Return the :class:`InputNode` for the meta node's input `channel`.
+ """
+ node = findf(self.input_nodes(),
+ lambda n: n.input_channels()[0] == channel)
+ if node is None:
+ raise ValueError
+ return node
+
+ def node_for_output_channel(self, channel: OutputSignal) -> 'OutputNode':
+ """
+ Return the :class:`OutputNode` for the meta node's output `channel`.
+ """
+ node = findf(self.output_nodes(),
+ lambda n: n.output_channels()[0] == channel)
+ if node is None:
+ raise ValueError
+ return node
+
+ def icon(self) -> QIcon:
+ return QIcon(SvgIconEngine(pkgutil.get_data("orangecanvas", "icons/MetaNode.svg")))
+
+ def _set_workflow(self, workflow: Optional['Workflow']) -> None:
+ super()._set_workflow(workflow)
+ for el in chain(self.__nodes, self.__links, self.__annotations):
+ el._set_workflow(workflow)
+
+ def __getstate__(self):
+ nodes = self.nodes()
+ links = self.links()
+ annotations = self.annotations()
+ return {
+ "title": self.title, "position": self.position,
+ "properties": self.properties,
+ "nodes": nodes,
+ "links": links,
+ "annotations": annotations,
+ }
+
+ def __setstate__(self, state):
+ title = state.pop("title")
+ position = state.pop("position")
+ properties = state.pop("properties")
+ nodes = state["nodes"]
+ links = state["links"]
+ annotations = state["annotations"]
+ self.__init__(title, position, properties=properties)
+ apply_all(self.add_node, nodes)
+ apply_all(self.add_link, links)
+ apply_all(self.add_annotation, annotations)
+
+
+class InputNode(Node):
+ """
+ An InputNode represents an input in a `MetaNode`.
+
+ It acts as a bridge between the parent `MetaNode`\\'s input and
+ its contents. I.e. inputs that are connected to the parent are
+ redispatched to this node within the `MetaNode` for use by the other
+ nodes.
+
+ Parameters
+ ----------
+ input: InputSignal
+ The parent meta nodes input signal.
+ output: OutputSignal
+ The corresponding output for this InputNode
+ """
+ def __init__(self, input: InputSignal, output: OutputSignal, **kwargs):
+ super().__init__(**kwargs)
+ self.sink_channel = input
+ self.source_channel = output
+ self.__input = input
+ self.__output = output
+
+ def input_channels(self): # type: () -> List[InputSignal]
+ return [self.__input]
+
+ def output_channels(self): # type: () -> List[OutputSignal]
+ return [self.__output]
+
+ def icon(self):
+ return QIcon(SvgIconEngine(pkgutil.get_data("orangecanvas", "icons/output.svg")))
+
+ def __reduce_ex__(self, protocol):
+ return reconstruct, (type(self), (), {
+ "input": self.__input, "output": self.__output, "title": self.title,
+ "position": self.position, "properties": self.properties
+ },)
+
+
+class OutputNode(Node):
+ """
+ An OutputNode represents an output in a `MetaNode`.
+
+ It acts as a bridge between the parent `MetaNode`\\'s output and
+ its contents. I.e. inputs that are connected to this node are
+ redispatched to the parent `MetaNode`\\s outputs for use by the other
+ nodes on the parent's node layer.
+
+ Parameters
+ ----------
+ input: InputSignal
+ The input output for this OutputNode
+ output: OutputSignal
+ The parent meta nodes output signal.
+ """
+ def __init__(self, input: InputSignal, output: OutputSignal, **kwargs):
+ super().__init__(**kwargs)
+ self.sink_channel = input
+ self.source_channel = output
+ self.__input = input
+ self.__output = output
+
+ def input_channels(self): # type: () -> List[InputSignal]
+ return [self.__input]
+
+ def output_channels(self): # type: () -> List[OutputSignal]
+ return [self.__output]
+
+ def icon(self):
+ return QIcon(SvgIconEngine(pkgutil.get_data("orangecanvas", "icons/input.svg")))
+
+ def __reduce_ex__(self, protocol):
+ return reconstruct, (type(self), (), {
+ "input": self.__input, "output": self.__output, "title": self.title,
+ "position": self.position, "properties": self.properties
+ },)
+
+
+def reconstruct(type, args, kwargs):
+ return type(*args, **kwargs)
+
+
+T = TypeVar("T")
+
+
+# helper utilities
+def find_links(
+ links: Iterable[Link],
+ source_node: Optional[Node] = None,
+ source_channel: Optional[OutputSignal] = None,
+ sink_node: Optional[Node] = None,
+ sink_channel: Optional[InputSignal] = None
+) -> List[Link]:
+ """
+ Find links from `links` that match the specified
+ {source,sink}_{node,channel} arguments (if `None` any will match) .
+ """
+ def match(query, value):
+ # type: (Optional[T], T) -> bool
+ return query is None or value == query
+ return [
+ link for link in links
+ if match(source_node, link.source_node) and
+ match(sink_node, link.sink_node) and
+ match(source_channel, link.source_channel) and
+ match(sink_channel, link.sink_channel)
+ ]
+
+
+def macro_link_step_in(link: Link) -> Node:
+ sink = link.sink_node
+ if isinstance(sink, MetaNode):
+ inputs = sink.input_nodes()
+ nodein = findf(inputs, lambda n: n.sink_channel == link.sink_channel)
+ assert nodein is not None
+ return nodein
+ else:
+ return sink
+
+
+def macro_link_short_circuit_back(link: Link) -> Node:
+ source = link.source_node
+ if isinstance(source, MetaNode):
+ outputs = source.output_nodes()
+ nodeout = findf(outputs,
+ lambda n: n.source_channel == link.source_channel)
+ assert nodeout is not None
+ return nodeout
+ else:
+ return source
+
+
+def node_dependents(node: Node) -> Sequence[Node]:
+ parent = node.parent_node()
+ links = []
+ if parent is None:
+ return []
+ if isinstance(node, OutputNode):
+ # step out of a macro
+ macro = parent
+ parent = macro.parent_node()
+ if parent is not None:
+ links = parent.find_links(macro, node.output_channels()[0])
+ else:
+ links = parent.find_links(node, None, None, None)
+ return list(unique(map(macro_link_step_in, links)))
+
+
+def node_dependencies(node) -> Sequence[Node]:
+ parent = node.parent_node()
+ links = []
+ if parent is None:
+ return []
+ if isinstance(node, InputNode):
+ # step out of a macro
+ macro = parent
+ parent = macro.parent_node()
+ if parent is not None:
+ links = parent.find_links(None, None, macro, node.input_channels()[0])
+ else:
+ links = parent.find_links(None, None, node, None)
+ return list(unique(map(macro_link_short_circuit_back, links)))
+
+
+def all_nodes_recursive(root: MetaNode) -> Iterable[Node]:
+ for node in root.nodes():
+ if isinstance(node, MetaNode):
+ yield node
+ yield from all_nodes_recursive(node)
+ else:
+ yield node
+
+
+def all_links_recursive(root: MetaNode) -> Iterable[Link]:
+ yield from root.links()
+ for node in root.nodes():
+ if isinstance(node, MetaNode):
+ yield from all_links_recursive(node)
+
+
+def all_annotations_recursive(root: MetaNode) -> Iterable[Annotation]:
+ yield from root.annotations()
+ for node in root.nodes():
+ if isinstance(node, MetaNode):
+ yield from all_annotations_recursive(node)
+
+
+def check_connect(existing: Sequence[Link], link: Link, flags=0) -> None:
+ """
+ Check if the `link` can be added to the `existing` and raise an
+ appropriate exception if not.
+
+ Can raise:
+ - :class:`.SchemeCycleError` if the `link` would introduce a loop
+ in the graph which does not allow loops.
+ - :class:`.IncompatibleChannelTypeError` if the channel types are
+ not compatible
+ - :class:`.SinkChannelError` if a sink channel has a `Single` flag
+ specification and the channel is already connected.
+ - :class:`.DuplicatedLinkError` if a `link` duplicates an already
+ present link.
+
+ """
+ from .scheme import Scheme
+ if not flags & Scheme.AllowSelfLoops and link.source_node is link.sink_node:
+ raise SchemeCycleError("Cannot create self cycle in the scheme")
+ elif not flags & Scheme.AllowLoops and creates_cycle(existing, link):
+ raise SchemeCycleError("Cannot create cycles in the scheme")
+
+ if not compatible_channels(link.source_channel, link.sink_channel):
+ raise IncompatibleChannelTypeError(
+ "Cannot connect %r to %r."
+ % (link.source_channel.type, link.sink_channel.type)
+ )
+
+ links = find_links(
+ existing,
+ source_node=link.source_node, source_channel=link.source_channel,
+ sink_node=link.sink_node, sink_channel=link.sink_channel
+ )
+ if links:
+ raise DuplicatedLinkError(
+ "A link from %r (%r) -> %r (%r) already exists"
+ % (link.source_node.title, link.source_channel.name,
+ link.sink_node.title, link.sink_channel.name)
+ )
+
+ if link.sink_channel.single:
+ links = find_links(
+ links, sink_node=link.sink_node, sink_channel=link.sink_channel
+ )
+ if links:
+ raise SinkChannelError(
+ "%r is already connected." % link.sink_channel.name
+ )
+
+
+def creates_cycle(existing: Sequence[Link], link: Link) -> bool:
+ """
+ Return `True` if `link` would introduce a cycle in the `links`.
+ """
+ def expand(node: Node) -> Sequence[Node]:
+ return [lnk.source_node for lnk in find_links(existing, sink_node=node)]
+ source_node, sink_node = link.source_node, link.sink_node
+ upstream = set(traverse_bf(source_node, expand))
+ upstream.add(source_node)
+ return sink_node in upstream
diff --git a/orangecanvas/scheme/node.py b/orangecanvas/scheme/node.py
index c933be4da..565e2632b 100644
--- a/orangecanvas/scheme/node.py
+++ b/orangecanvas/scheme/node.py
@@ -10,9 +10,14 @@
from AnyQt.QtCore import QObject, QCoreApplication
from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property
+from AnyQt.QtGui import QIcon
from ..registry import WidgetDescription, InputSignal, OutputSignal
-from .events import NodeEvent
+from .events import NodeEvent, NodeInputChannelEvent, NodeOutputChannelEvent
+from .element import Element
+from ..resources import icon_loader
+
+Pos = Tuple[float, float]
class UserMessage(object):
@@ -41,23 +46,13 @@ def __init__(self, contents, severity=Info, message_id="", data={}):
self.data = dict(data)
-class SchemeNode(QObject):
+class Node(Element):
"""
- A node in a :class:`.Scheme`.
-
- Parameters
- ----------
- description : :class:`WidgetDescription`
- Node description instance.
- title : str, optional
- Node title string (if None `description.name` is used).
- position : tuple
- (x, y) tuple of floats for node position in a visual display.
- properties : dict
- Additional extra instance properties (settings, widget geometry, ...)
- parent : :class:`QObject`
- Parent object.
+ Common base class of all other workflow graph nodes.
+ This class should not be instantiated directly use one of
+ :class:`SchemeNode`, :class:`MetaNode`, :class:`InputNode` and
+ :class:`OutputNode`.
"""
class State(enum.IntEnum):
"""
@@ -94,38 +89,102 @@ class State(enum.IntEnum):
Invalidated = State.Invalidated
NotReady = State.NotReady
- def __init__(self, description, title=None, position=None,
- properties=None, parent=None):
- # type: (WidgetDescription, str, Tuple[float, float], dict, QObject) -> None
- super().__init__(parent)
- self.description = description
-
- if title is None:
- title = description.name
-
+ def __init__(
+ self, title: str = "", position: Pos = (0, 0),
+ properties: Optional[dict] = None, parent: Optional[QObject] = None,
+ **kwargs
+ ):
+ super().__init__(parent, **kwargs)
+ self.__parent_node = None
self.__title = title
- self.__position = position or (0, 0)
+ self.__position = position
+ self.__properties = properties or {}
+ self.__title = title
+ self.__position = position
self.__progress = -1
self.__processing_state = 0
self.__tool_tip = ""
self.__status_message = ""
self.__state_messages = {} # type: Dict[str, UserMessage]
- self.__state = SchemeNode.NoState # type: Union[SchemeNode.State, int]
+ self.__state = Node.NoState # type: Union[Node.State, int]
+ # I/O channels added at runtime/config
+ self.__inputs = [] # type: List[InputSignal]
+ self.__outputs = [] # type: List[OutputSignal]
self.properties = properties or {}
+ #: Signal emitted when an input channel is inserted
+ input_channel_inserted = Signal(int, InputSignal)
+ #: Signal emitted when an input channel is removed
+ input_channel_removed = Signal(int, InputSignal)
+
+ def add_input_channel(self, signal: InputSignal):
+ """Add `signal` input channel."""
+ self.insert_input_channel(len(self.input_channels()), signal)
+
+ def insert_input_channel(self, index, signal: InputSignal):
+ """Insert the `signal` input channel at `index`"""
+ self.__inputs.insert(index, signal)
+ ev = NodeInputChannelEvent(NodeEvent.InputChannelAdded, self, signal, index)
+ QCoreApplication.sendEvent(self, ev)
+ w = self.workflow()
+ if w is not None:
+ QCoreApplication.sendEvent(self, ev)
+ self.input_channel_inserted.emit(index, signal)
+
+ def remove_input_channel(self, index) -> InputSignal:
+ """Remove the input channel at `index`"""
+ r = self.__inputs.pop(index)
+ ev = NodeInputChannelEvent(NodeEvent.InputChannelRemoved, self, r, index)
+ QCoreApplication.sendEvent(self, ev)
+ w = self.workflow()
+ if w is not None:
+ QCoreApplication.sendEvent(w, ev)
+ self.input_channel_removed.emit(index, r)
+ return r
+
def input_channels(self):
# type: () -> List[InputSignal]
"""
Return a list of input channels (:class:`InputSignal`) for the node.
"""
- return list(self.description.inputs)
+ return list(self.__inputs)
+
+ #: Signal emitted when an output channel is inserted.
+ output_channel_inserted = Signal(int, OutputSignal)
+ #: Signal emitted when an output channel is removed.
+ output_channel_removed = Signal(int, OutputSignal)
+
+ def add_output_channel(self, signal: OutputSignal):
+ """Add `signal` output channel."""
+ self.insert_output_channel(len(self.output_channels()), signal)
+
+ def insert_output_channel(self, index, signal: OutputSignal):
+ """Insert the `signal` output channel at `index`"""
+ self.__outputs.insert(index, signal)
+ ev = NodeOutputChannelEvent(NodeEvent.OutputChannelAdded, self, signal, index)
+ QCoreApplication.sendEvent(self, ev)
+ w = self.workflow()
+ if w is not None:
+ QCoreApplication.sendEvent(w, ev)
+ self.output_channel_inserted.emit(index, signal)
+
+ def remove_output_channel(self, index) -> OutputSignal:
+ """Remove the output channel at `index`"""
+ r = self.__outputs.pop(index)
+ ev = NodeOutputChannelEvent(NodeEvent.OutputChannelRemoved, self, r, index)
+ QCoreApplication.sendEvent(self, ev)
+ w = self.workflow()
+ if w is not None:
+ QCoreApplication.sendEvent(w, ev)
+ self.output_channel_removed.emit(index, r)
+ return r
def output_channels(self):
# type: () -> List[OutputSignal]
"""
Return a list of output channels (:class:`OutputSignal`) for the node.
"""
- return list(self.description.outputs)
+ return list(self.__outputs)
def input_channel(self, name):
# type: (str) -> InputSignal
@@ -140,8 +199,8 @@ def input_channel(self, name):
for channel in self.input_channels():
if channel.name == name:
return channel
- raise ValueError("%r is not a valid input channel for %r." % \
- (name, self.description.name))
+ raise ValueError("%r is not a valid input channel for %r." %
+ (name, self.title))
def output_channel(self, name):
# type: (str) -> OutputSignal
@@ -156,8 +215,8 @@ def output_channel(self, name):
for channel in self.output_channels():
if channel.name == name:
return channel
- raise ValueError("%r is not a valid output channel for %r." % \
- (name, self.description.name))
+ raise ValueError("%r is not a valid output channel for %r." %
+ (name, self.title))
#: The title of the node has changed
title_changed = Signal(str)
@@ -179,6 +238,9 @@ def _title(self):
title: str
title = Property(str, _title, set_title) # type: ignore
+ def icon(self) -> QIcon:
+ return QIcon()
+
#: Position of the node in the scheme has changed
position_changed = Signal(tuple)
@@ -226,13 +288,13 @@ def set_processing_state(self, state):
"""
Set the node processing state.
"""
- self.set_state_flags(SchemeNode.Running, bool(state))
+ self.set_state_flags(Node.Running, bool(state))
def _processing_state(self):
"""
The node processing state, 0 for not processing, 1 the node is busy.
"""
- return int(bool(self.state() & SchemeNode.Running))
+ return int(bool(self.state() & Node.Running))
processing_state: int
processing_state = Property( # type: ignore
@@ -321,7 +383,7 @@ def set_state(self, state):
Parameters
----------
- state: SchemeNode.State
+ state: Node.State
"""
if self.__state != state:
curr = self.__state
@@ -330,9 +392,9 @@ def set_state(self, state):
self, NodeEvent(NodeEvent.NodeStateChange, self)
)
self.state_changed.emit(state)
- if curr & SchemeNode.Running != state & SchemeNode.Running:
+ if curr & Node.Running != state & Node.Running:
self.processing_state_changed.emit(
- int(bool(state & SchemeNode.Running))
+ int(bool(state & Node.Running))
)
def state(self):
@@ -349,7 +411,7 @@ def set_state_flags(self, flags, on):
Parameters
----------
- flags: SchemeNode.State
+ flags: Node.State
Flag to modify
on: bool
Turn the flag on or off
@@ -367,7 +429,7 @@ def test_state_flags(self, flag):
Parameters
----------
- flag: SchemeNode.State
+ flag: Node.State
Returns
-------
@@ -375,6 +437,108 @@ def test_state_flags(self, flag):
"""
return bool(self.__state & flag)
+
+class SchemeNode(Node):
+ """
+ A node in a :class:`.Scheme`.
+
+ Parameters
+ ----------
+ description : :class:`WidgetDescription`
+ Node description instance.
+ title : str, optional
+ Node title string (if None `description.name` is used).
+ position : tuple
+ (x, y) tuple of floats for node position in a visual display.
+ properties : dict
+ Additional extra instance properties (settings, widget geometry, ...)
+ parent : :class:`QObject`
+ Parent object.
+ """
+ def __init__(self, description, title=None, position=(0, 0),
+ properties=None, parent=None):
+ # type: (WidgetDescription, str, Pos, dict, QObject) -> None
+ if title is None:
+ title = description.name
+
+ super().__init__(title, position, properties or {}, parent=parent)
+ self.description = description
+ # I/O channels added at runtime/config
+ self.__inputs = [] # type: List[InputSignal]
+ self.__outputs = [] # type: List[OutputSignal]
+
+ def input_channels(self):
+ # type: () -> List[InputSignal]
+ """
+ Return a list of input channels (:class:`InputSignal`) for the node.
+ """
+ return list(self.description.inputs) + self.__inputs
+
+ input_channel_inserted = Signal(int, InputSignal)
+ input_channel_removed = Signal(int, InputSignal)
+
+ def add_input_channel(self, signal: InputSignal):
+ self.insert_input_channel(len(self.input_channels()), signal)
+
+ def insert_input_channel(self, index, signal: InputSignal):
+ inputs = self.description.inputs
+ if 0 <= index < len(inputs):
+ raise IndexError("Cannot insert into predefined inputs")
+ self.__inputs.insert(index - len(inputs), signal)
+ QCoreApplication.sendEvent(
+ self, NodeInputChannelEvent(NodeEvent.InputChannelAdded, self, signal, index)
+ )
+ self.input_channel_inserted.emit(index, signal)
+
+ def remove_input_channel(self, index) -> InputSignal:
+ inputs = self.description.inputs
+ if 0 <= index < len(inputs):
+ raise IndexError("Cannot remove predefined inputs")
+ r = self.__inputs.pop(index - len(inputs))
+ QCoreApplication.sendEvent(
+ self, NodeInputChannelEvent(NodeEvent.InputChannelRemoved, self, r, index)
+ )
+ self.input_channel_removed.emit(index, r)
+ return r
+
+ def output_channels(self):
+ # type: () -> List[OutputSignal]
+ """
+ Return a list of output channels (:class:`OutputSignal`) for the node.
+ """
+ return list(self.description.outputs) + self.__outputs
+
+ output_channel_inserted = Signal(int, OutputSignal)
+ output_channel_removed = Signal(int, OutputSignal)
+
+ def add_output_channel(self, signal: OutputSignal):
+ self.insert_output_channel(len(self.output_channels()), signal)
+
+ def insert_output_channel(self, index, signal: OutputSignal):
+ outputs = self.description.outputs
+ if 0 <= index < len(outputs):
+ raise IndexError("Cannot insert into predefined outputs")
+ self.__outputs.insert(index - len(outputs), signal)
+ QCoreApplication.sendEvent(
+ self, NodeOutputChannelEvent(NodeEvent.OutputChannelAdded, self, signal, index)
+ )
+ self.output_channel_inserted.emit(index, signal)
+
+ def remove_output_channel(self, index) -> OutputSignal:
+ outputs = self.description.outputs
+ if 0 <= index < len(outputs):
+ raise IndexError("Cannot remove predefined output")
+ r = self.__outputs.pop(index - len(outputs))
+ QCoreApplication.sendEvent(
+ self, NodeOutputChannelEvent(NodeEvent.OutputChannelRemoved, self, r, index)
+ )
+ self.output_channel_removed.emit(index, r)
+ return r
+
+ def icon(self) -> QIcon:
+ desc = self.description
+ return icon_loader.from_description(desc).get(desc.icon)
+
def __str__(self):
return "SchemeNode(description_id=%r, title=%r, ...)" % \
(str(self.description.id), self.title)
@@ -384,10 +548,16 @@ def __repr__(self):
def __getstate__(self):
return self.description, \
- self.__title, \
- self.__position, \
+ self.title, \
+ self.position, \
self.properties, \
- self.parent()
+ self.__inputs, \
+ self.__outputs
def __setstate__(self, state):
+ *state, inputs, outputs = state
self.__init__(*state)
+ for ic in self.__inputs:
+ self.add_input_channel(ic)
+ for oc in self.__outputs:
+ self.add_output_channel(oc)
diff --git a/orangecanvas/scheme/readwrite.py b/orangecanvas/scheme/readwrite.py
index 27e70aa36..195d379a8 100644
--- a/orangecanvas/scheme/readwrite.py
+++ b/orangecanvas/scheme/readwrite.py
@@ -5,12 +5,9 @@
import numbers
import base64
import binascii
-import itertools
import math
from xml.etree.ElementTree import TreeBuilder, Element, ElementTree, parse
-
-from collections import defaultdict
from itertools import chain
import pickle
@@ -28,13 +25,16 @@
from typing_extensions import TypeGuard
-from . import SchemeNode, SchemeLink
-from .annotations import SchemeTextAnnotation, SchemeArrowAnnotation
+from .node import SchemeNode, Node
+from .metanode import MetaNode, InputNode, OutputNode
+from .link import Link
+from .annotations import SchemeTextAnnotation, SchemeArrowAnnotation, Annotation
from .errors import IncompatibleChannelTypeError
from ..registry import global_registry, WidgetRegistry
from ..registry import WidgetDescription, InputSignal, OutputSignal
from ..utils import findf
+from ..registry.description import Multiple, Single
log = logging.getLogger(__name__)
@@ -124,93 +124,275 @@ def _is_constant(
# Intermediate scheme representation
-_scheme = NamedTuple(
- "_scheme", [
- ("title", str),
- ("version", str),
- ("description", str),
- ("nodes", List['_node']),
- ("links", List['_link']),
- ("annotations", List['_annotation']),
- ("session_state", '_session_data')
- ]
-)
+class _scheme(NamedTuple):
+ title: str
+ version: str
+ description: str
+ nodes: List['_NodeType']
+ links: List['_link']
+ annotations: List['_annotation']
+ session_state: '_session_data'
+
+ @property
+ def root(self):
+ return _macro_node(
+ id="", title=self.title, position=(0., 0.), version="",
+ nodes=self.nodes, links=self.links, annotations=self.annotations,
+ )
-_node = NamedTuple(
- "_node", [
- ("id", str),
- ("title", str),
- ("name", str),
- ("position", Tuple[float, float]),
- ("project_name", str),
- ("qualified_name", str),
- ("version", str),
- ("data", Optional['_data'])
- ]
-)
+ def iter_all_nodes(self):
+ def all_nodes(root):
+ child_nodes = getattr(root, "nodes", [])
+ yield from chain([root], *(all_nodes(c) for c in child_nodes))
+ yield from chain.from_iterable(all_nodes(c) for c in self.nodes)
-_data = NamedTuple(
- "_data", [
- ("format", str),
- ("data", Union[bytes, str])
- ]
-)
-_link = NamedTuple(
- "_link", [
- ("id", str),
- ("source_node_id", str),
- ("sink_node_id", str),
- ("source_channel", str),
- ("source_channel_id", str),
- ("sink_channel", str),
- ("sink_channel_id", str),
- ("enabled", bool),
- ]
-)
+class _node(NamedTuple):
+ id: str
+ title: str
+ position: Tuple[float, float]
+ project_name: str
+ qualified_name: str
+ version: str
+ added_inputs: Tuple[dict, ...]
+ added_outputs: Tuple[dict, ...]
+ data: Optional['_data']
-_annotation = NamedTuple(
- "_annotation", [
- ("id", str),
- ("type", str),
- ("params", Union['_text_params', '_arrow_params']),
- ]
-)
-_text_params = NamedTuple(
- "_text_params", [
- ("geometry", Tuple[float, float, float, float]),
- ("text", str),
- ("font", Dict[str, Any]),
- ("content_type", str),
- ]
-)
+class _macro_node(NamedTuple):
+ id: str
+ title: str
+ position: Tuple[float, float]
+ version: str
+ nodes: List['_NodeType']
+ links: List['_link']
+ annotations: List['_annotation']
-_arrow_params = NamedTuple(
- "_arrow_params", [
- ("geometry", Tuple[Tuple[float, float], Tuple[float, float]]),
- ("color", str),
- ])
-
-_window_group = NamedTuple(
- "_window_group", [
- ("name", str),
- ("default", bool),
- ("state", List[Tuple[str, bytes]])
- ]
-)
-_session_data = NamedTuple(
- "_session_data", [
- ("groups", List[_window_group])
- ]
-)
+class _input_node(NamedTuple):
+ id: str
+ title: str
+ position: Tuple[float, float]
+ type: Tuple[str, ...]
+ multiple: bool = False
+
+
+class _output_node(NamedTuple):
+ id: str
+ title: str
+ position: Tuple[float, float]
+ type: Tuple[str, ...]
+
+
+_NodeType = Union[_node, _macro_node, _input_node, _output_node]
+
+
+class _data(NamedTuple):
+ format: str
+ data: Union[bytes, str]
+
+
+class _link(NamedTuple):
+ id: str
+ source_node_id: str
+ sink_node_id: str
+ source_channel: str
+ source_channel_id: str
+ sink_channel: str
+ sink_channel_id: str
+ enabled: bool
+
+
+class _annotation(NamedTuple):
+ id: str
+ type: str
+ params: Union['_text_params', '_arrow_params']
+
+
+class _text_params(NamedTuple):
+ geometry: Tuple[float, float, float, float]
+ text: str
+ font: Dict[str, Any]
+ content_type: str
+
+
+class _arrow_params(NamedTuple):
+ geometry: Tuple[Tuple[float, float], Tuple[float, float]]
+ color: str
+
+
+class _window_group(NamedTuple):
+ name: str
+ default: bool
+ state: List[Tuple[str, bytes]]
+
+
+class _session_data(NamedTuple):
+ groups: List[_window_group]
+
+
+def _parse_ows_etree_node(node: Element) -> _NodeType:
+ node_id = node.get("id")
+ px, py = tuple_eval(node.get("position"))
+ added_inputs = []
+ added_outputs = []
+
+ for ai in node.findall("added_input"):
+ added_inputs.append(dict(ai.attrib))
+ for ao in node.findall("added_output"):
+ added_outputs.append(dict(ao.attrib))
+
+ return _node( # type: ignore
+ id=node_id,
+ title=node.get("title"),
+ # name=node.get("name"),
+ position=(px, py),
+ project_name=node.get("project_name"),
+ qualified_name=node.get("qualified_name"),
+ version=node.get("version", ""),
+ added_inputs=tuple(added_inputs),
+ added_outputs=tuple(added_outputs),
+ data=None
+ )
+
+
+def _parse_ows_etree_macro_node(node: Element) -> _macro_node:
+ node_id = node.get("id")
+ px, py = tuple_eval(node.get("position", "0, 0"))
+ nodes, links, annotations = [], [], []
+
+ for enode in node.findall("./nodes/*"):
+ parser = _parse_ows_etree_node_dispatch[enode.tag]
+ nodes.append(parser(enode))
+ for elink in node.findall("./links/link"):
+ links.append(_parse_ows_etree_link(elink))
+ for eannot in node.findall("./annotations/*"):
+ parser = _parse_ows_etree_annotation_dispatch.get(eannot.tag, None)
+ if parser is not None:
+ annotations.append(parser(eannot))
+ else:
+ log.warning("Unknown annotation '%s'. Skipping.", eannot.tag)
+
+ return _macro_node(
+ id=node_id,
+ title=node.get("title"),
+ position=(px, py),
+ nodes=nodes,
+ links=links,
+ annotations=annotations,
+ version=node.get("version", ""),
+ )
+
+
+def parse_type_spec(string) -> Tuple[str, ...]:
+ parts = [s.strip() for s in string.split("|")]
+ return tuple(parts)
+
+
+def _parse_ows_etree_input_node(node: Element) -> _input_node:
+ node_id = node.get("id")
+ px, py = tuple_eval(node.get("position"))
+ types = parse_type_spec(node.get("type", ""))
+ return _input_node(
+ id=node_id,
+ title=node.get("title"),
+ position=(px, py),
+ type=types,
+ multiple=node.get("multiple", "false") == "true"
+ )
+
+
+def _parse_ows_etree_output_node(node: Element) -> _output_node:
+ node_id = node.get("id")
+ px, py = tuple_eval(node.get("position"))
+ types = parse_type_spec(node.get("type", ""))
+ return _output_node(
+ id=node_id,
+ title=node.get("title"),
+ position=(px, py),
+ type=types
+ )
+
+
+def _parse_ows_etree_link(link: Element):
+ return _link(
+ id=link.get("id"),
+ source_node_id=link.get("source_node_id"),
+ sink_node_id=link.get("sink_node_id"),
+ source_channel=link.get("source_channel"),
+ source_channel_id=link.get("source_channel_id", ""),
+ sink_channel=link.get("sink_channel"),
+ sink_channel_id=link.get("sink_channel_id", ""),
+ enabled=link.get("enabled", "true") == "true",
+ )
+
+
+def _parse_ows_etree_text_annotation(annot: Element) -> _annotation:
+ rect = tuple_eval(annot.get("rect", "0.0, 0.0, 20.0, 20.0"))
+ font_family = annot.get("font-family", "").strip()
+ font_size = annot.get("font-size", "").strip()
+ font = {} # type: Dict[str, Any]
+ if font_family:
+ font["family"] = font_family
+ if font_size:
+ font["size"] = int(font_size)
+
+ content_type = annot.get("type", "text/plain")
+
+ return _annotation(
+ id=annot.get("id"),
+ type="text",
+ params=_text_params( # type: ignore
+ rect, annot.text or "", font, content_type),
+ )
+
+
+def _parse_ows_etree_arrow_annotation(annot: Element) -> _annotation:
+ start = tuple_eval(annot.get("start", "0, 0"))
+ end = tuple_eval(annot.get("end", "0, 0"))
+ color = annot.get("fill", "red")
+ return _annotation(
+ id=annot.get("id"),
+ type="arrow",
+ params=_arrow_params((start, end), color)
+ )
+
+
+def _parse_ows_etree_window_group(group: Element) -> _window_group:
+ name = group.get("name") # type: str
+ default = group.get("default", "false") == "true"
+ state = []
+ for state_ in group.findall("./window_state"):
+ node_id = state_.get("node_id")
+ text_ = state_.text
+ if text_ is not None:
+ try:
+ data = base64.decodebytes(text_.encode("ascii"))
+ except (binascii.Error, UnicodeDecodeError):
+ data = b''
+ else:
+ data = b''
+ state.append((node_id, data))
+ return _window_group(name, default, state)
+
+
+_parse_ows_etree_node_dispatch = {
+ "node": _parse_ows_etree_node,
+ "input_node": _parse_ows_etree_input_node,
+ "output_node": _parse_ows_etree_output_node,
+ "macro_node": _parse_ows_etree_macro_node,
+}
+
+_parse_ows_etree_annotation_dispatch = {
+ "text": _parse_ows_etree_text_annotation,
+ "arrow": _parse_ows_etree_arrow_annotation,
+}
def parse_ows_etree_v_2_0(tree):
# type: (ElementTree) -> _scheme
"""
- Parset an xml.etree.ElementTree struct into a intermediate workflow
+ Parse an xml.etree.ElementTree struct into a intermediate workflow
representation.
"""
scheme = tree.getroot()
@@ -232,15 +414,25 @@ def parse_ows_etree_v_2_0(tree):
for node in tree.findall("nodes/node"):
node_id = node.get("id")
_px, _py = tuple_eval(node.get("position"))
+ added_inputs = []
+ added_outputs = []
+
+ for ai in node.findall("added_input"):
+ added_inputs.append(dict(ai.attrib))
+ for ao in node.findall("added_output"):
+ added_outputs.append(dict(ao.attrib))
+
nodes.append(
_node( # type: ignore
id=node_id,
title=node.get("title"),
- name=node.get("name"),
+ # name=node.get("name"),
position=(_px, _py),
project_name=node.get("project_name"),
qualified_name=node.get("qualified_name"),
version=node.get("version", ""),
+ added_inputs=tuple(added_inputs),
+ added_outputs=tuple(added_outputs),
data=properties.get(node_id, None)
)
)
@@ -260,7 +452,7 @@ def parse_ows_etree_v_2_0(tree):
for annot in tree.findall("annotations/*"):
if annot.tag == "text":
- rect = tuple_eval(annot.get("rect", "(0.0, 0.0, 20.0, 20.0)"))
+ rect = tuple_eval(annot.get("rect", "0.0, 0.0, 20.0, 20.0"))
font_family = annot.get("font-family", "").strip()
font_size = annot.get("font-size", "").strip()
@@ -280,8 +472,8 @@ def parse_ows_etree_v_2_0(tree):
rect, annot.text or "", font, content_type),
)
elif annot.tag == "arrow":
- start = tuple_eval(annot.get("start", "(0, 0)"))
- end = tuple_eval(annot.get("end", "(0, 0)"))
+ start = tuple_eval(annot.get("start", "0, 0"))
+ end = tuple_eval(annot.get("end", "0, 0"))
color = annot.get("fill", "red")
annotation = _annotation(
id=annot.get("id"),
@@ -326,6 +518,53 @@ def parse_ows_etree_v_2_0(tree):
)
+def parse_ows_etree_v_3_0(tree):
+ # type: (ElementTree) -> _scheme
+ """
+ Parse an xml.etree.ElementTree struct into a intermediate workflow
+ representation.
+ """
+ scheme = tree.getroot()
+ version = scheme.get("version")
+
+ # First collect all properties
+ properties = {} # type: Dict[str, _data]
+ for property in tree.findall("node_properties/properties"):
+ node_id = property.get("node_id") # type: str
+ format = property.get("format")
+ if version == "2.0" and "data" in property.attrib:
+ data_str = property.get("data", default="")
+ else:
+ data_str = property.text or ""
+ properties[node_id] = _data(format, data_str)
+
+ root = _parse_ows_etree_macro_node(scheme)
+
+ def add_node_data(node: _NodeType) -> _NodeType:
+ if isinstance(node, _macro_node):
+ return node._replace(nodes=[add_node_data(n) for n in node.nodes])
+ elif isinstance(node, _node):
+ return node._replace(data=properties.get(node.id, None))
+ else:
+ return node
+ window_presets = []
+
+ for window_group in tree.findall("session_state/window_groups/group"):
+ window_presets.append(_parse_ows_etree_window_group(window_group))
+ session_state = _session_data(window_presets)
+ root = root._replace(nodes=[add_node_data(n) for n in root.nodes])
+
+ return _scheme(
+ version=version,
+ title=scheme.get("title", ""),
+ description=scheme.get("description"),
+ nodes=root.nodes,
+ links=root.links,
+ annotations=root.annotations,
+ session_state=session_state,
+ )
+
+
class InvalidFormatError(ValueError):
pass
@@ -354,8 +593,11 @@ def parse_ows_stream(stream):
raise InvalidFormatError(
"Invalid Orange Workflow Scheme file (missing version)."
)
- if version in {"2.0", "2.1"}:
+ version_info = tuple(map(int, version.split(".")))
+ if (2, 0) <= version_info < (3, 0):
return parse_ows_etree_v_2_0(doc)
+ elif (3, 0) <= version_info < (4, 0):
+ return parse_ows_etree_v_3_0(doc)
else:
raise UnsupportedFormatVersionError(
f"Unsupported format version {version}")
@@ -383,41 +625,51 @@ def resolve_replaced(scheme_desc: _scheme, registry: WidgetRegistry) -> _scheme:
replacements_channels[desc.qualified_name] = (input_repl, output_repl)
# replace the nodes
- nodes = scheme_desc.nodes
- for i, node in list(enumerate(nodes)):
- if not registry.has_widget(node.qualified_name) and \
- node.qualified_name in replacements:
- qname = replacements[node.qualified_name]
- desc = registry.widget(qname)
- nodes[i] = node._replace(qualified_name=desc.qualified_name,
- project_name=desc.project_name)
- nodes_by_id[node.id] = nodes[i]
-
- # replace links
- links = scheme_desc.links
- for i, link in list(enumerate(links)):
- nsource = nodes_by_id[link.source_node_id]
- nsink = nodes_by_id[link.sink_node_id]
-
- _, source_rep = replacements_channels.get(
- nsource.qualified_name, ({}, {}))
- sink_rep, _ = replacements_channels.get(
- nsink.qualified_name, ({}, {}))
-
- if link.source_channel in source_rep:
- link = link._replace(
- source_channel=source_rep[link.source_channel])
- if link.sink_channel in sink_rep:
- link = link._replace(
- sink_channel=sink_rep[link.sink_channel])
- links[i] = link
-
- return scheme_desc._replace(nodes=nodes, links=links)
+ def replace_macro(root: _macro_node):
+ nodes = root.nodes
+ for i, node in list(enumerate(nodes)):
+ if isinstance(node, _node) and \
+ not registry.has_widget(node.qualified_name) and \
+ node.qualified_name in replacements:
+ qname = replacements[node.qualified_name]
+ desc = registry.widget(qname)
+ nodes[i] = node._replace(qualified_name=desc.qualified_name,
+ project_name=desc.project_name)
+ if isinstance(node, _macro_node):
+ nodes[i] = replace_macro(node)
+ nodes_by_id[node.id] = nodes[i]
+
+ # replace links
+ links = root.links
+ for i, link in list(enumerate(links)):
+ nsource = nodes_by_id[link.source_node_id]
+ nsink = nodes_by_id[link.sink_node_id]
+ source_rep = sink_rep = {}
+ if isinstance(nsource, _node):
+ _, source_rep = replacements_channels.get(
+ nsource.qualified_name, ({}, {}))
+ if isinstance(nsink, _node):
+ sink_rep, _ = replacements_channels.get(
+ nsink.qualified_name, ({}, {}))
+
+ if link.source_channel in source_rep:
+ link = link._replace(
+ source_channel=source_rep[link.source_channel])
+ if link.sink_channel in sink_rep:
+ link = link._replace(
+ sink_channel=sink_rep[link.sink_channel])
+ links[i] = link
+ return root._replace(nodes=nodes, links=links)
+ root = _macro_node(
+ "", "", (0, 0), "", scheme_desc.nodes, scheme_desc.links,
+ scheme_desc.annotations
+ )
+ root = replace_macro(root)
+ return scheme_desc._replace(nodes=root.nodes, links=root.links)
def scheme_load(scheme, stream, registry=None, error_handler=None):
desc = parse_ows_stream(stream) # type: _scheme
-
if registry is None:
registry = global_registry()
@@ -427,81 +679,125 @@ def error_handler(exc):
desc = resolve_replaced(desc, registry)
nodes_not_found = []
- nodes = []
nodes_by_id = {}
links = []
- annotations = []
scheme.title = desc.title
scheme.description = desc.description
+ root = scheme.root()
- for node_d in desc.nodes:
+ def build_scheme_node(node: _node, parent: MetaNode) -> SchemeNode:
try:
- w_desc = registry.widget(node_d.qualified_name)
+ desc = registry.widget(node.qualified_name)
except KeyError as ex:
error_handler(UnknownWidgetDefinition(*ex.args))
- nodes_not_found.append(node_d.id)
+ nodes_not_found.append(node.id)
else:
- node = SchemeNode(
- w_desc, title=node_d.title, position=node_d.position)
- data = node_d.data
-
- if data:
+ snode = SchemeNode(
+ desc, title=node.title, position=node.position)
+ for ai in node.added_inputs:
+ snode.add_input_channel(
+ InputSignal(ai["name"], ai["type"], "")
+ )
+ for ao in node.added_outputs:
+ snode.add_output_channel(
+ OutputSignal(ao["name"], ao["type"])
+ )
+ if node.data:
try:
- properties = loads(data.data, data.format)
+ properties = loads(node.data.data, node.data.format)
except Exception:
log.error("Could not load properties for %r.", node.title,
exc_info=True)
else:
- node.properties = properties
-
- nodes.append(node)
- nodes_by_id[node_d.id] = node
-
- for link_d in desc.links:
- source_id = link_d.source_node_id
- sink_id = link_d.sink_node_id
-
- if source_id in nodes_not_found or sink_id in nodes_not_found:
- continue
-
- source = nodes_by_id[source_id]
- sink = nodes_by_id[sink_id]
- try:
- source_channel = _find_source_channel(source, link_d)
- sink_channel = _find_sink_channel(sink, link_d)
- link = SchemeLink(source, source_channel,
- sink, sink_channel,
- enabled=link_d.enabled)
- except (ValueError, IncompatibleChannelTypeError) as ex:
- error_handler(ex)
- else:
- links.append(link)
-
- for annot_d in desc.annotations:
- params = annot_d.params
- if annot_d.type == "text":
- annot = SchemeTextAnnotation(
- params.geometry, params.text, params.content_type,
- params.font
- )
- elif annot_d.type == "arrow":
- start, end = params.geometry
- annot = SchemeArrowAnnotation(start, end, params.color)
-
- else:
- log.warning("Ignoring unknown annotation type: %r", annot_d.type)
- continue
- annotations.append(annot)
-
- for node in nodes:
- scheme.add_node(node)
-
- for link in links:
- scheme.add_link(link)
-
- for annot in annotations:
- scheme.add_annotation(annot)
+ snode.properties = properties
+ parent.add_node(snode)
+ nodes_by_id[node.id] = snode
+ return snode
+
+ def build_macro_node(node: _macro_node, parent: MetaNode) -> MetaNode:
+ meta = WidgetDescription(
+ "Macro", id="orangecanvas.meta", category="Utils/Meta",
+ qualified_name="orangecanvas.scheme.node.MetaNode",
+ inputs=[], outputs=[]
+ )
+ mnode = MetaNode(node.title, position=node.position)
+ mnode.description = meta
+ parent.add_node(mnode)
+ nodes_by_id[node.id] = mnode
+ build_macro_helper(mnode, node)
+ mnode.description.inputs = tuple(mnode.input_channels())
+ mnode.description.outputs = tuple(mnode.output_channels())
+ return mnode
+
+ def build_macro_helper(mnode: MetaNode, node: _macro_node):
+ for node_d in node.nodes:
+ node_dispatch[type(node_d)](node_d, mnode)
+
+ for link_d in node.links:
+ source_id = link_d.source_node_id
+ sink_id = link_d.sink_node_id
+ if source_id in nodes_not_found or sink_id in nodes_not_found:
+ continue
+ source = nodes_by_id[source_id]
+ sink = nodes_by_id[sink_id]
+ try:
+ link = Link(
+ source, _find_source_channel(source, link_d),
+ sink, _find_sink_channel(sink, link_d),
+ enabled=link_d.enabled
+ )
+ except (ValueError, IncompatibleChannelTypeError) as ex:
+ error_handler(ex)
+ else:
+ mnode.add_link(link)
+
+ for annot_d in node.annotations:
+ params = annot_d.params
+ if annot_d.type == "text":
+ annot = SchemeTextAnnotation(
+ params.geometry, params.text, params.content_type,
+ params.font
+ )
+ elif annot_d.type == "arrow":
+ start, end = params.geometry
+ annot = SchemeArrowAnnotation(start, end, params.color)
+ else:
+ log.warning("Ignoring unknown annotation type: %r",
+ annot_d.type)
+ continue
+ mnode.add_annotation(annot)
+ return mnode
+
+ def build_input_node(node: _input_node, parent: MetaNode) -> InputNode:
+ flags = Multiple if node.multiple else Single
+ inode = InputNode(
+ InputSignal(node.title, node.type, "", flags=flags),
+ OutputSignal(node.title, node.type, flags=flags),
+ title=node.title, position=node.position
+ )
+ parent.add_node(inode)
+ nodes_by_id[node.id] = inode
+ return inode
+
+ def build_output_node(node: _output_node, parent: MetaNode) -> OutputNode:
+ onode = OutputNode(
+ InputSignal(node.title, node.type, ""),
+ OutputSignal(node.title, node.type),
+ title=node.title, position=node.position
+ )
+ parent.add_node(onode)
+ nodes_by_id[node.id] = onode
+ return onode
+
+ node_dispatch = {
+ _node: build_scheme_node, _macro_node: build_macro_node,
+ _input_node: build_input_node, _output_node: build_output_node
+ }
+ _root_node = _macro_node(
+ "", "", (0., 0.), "", desc.nodes, desc.links, desc.annotations
+ )
+ build_macro_helper(root, _root_node)
if desc.session_state.groups:
groups = []
@@ -556,132 +852,475 @@ def _find_sink_channel(node: SchemeNode, link: _link) -> InputSignal:
f"for {node.description.name!r}."
)
+def _meta_node_to_interm(node: MetaNode, ids) -> _macro_node:
+ nodes = []
+ node_dispatch = {
+ SchemeNode: _node_to_interm,
+ MetaNode: _meta_node_to_interm,
+ OutputNode: _output_node_to_interm,
+ InputNode: _input_node_to_interm,
+ }
+ for n in node.nodes():
+ nodes.append(node_dispatch[type(n)](n, ids))
+ links = [_link_to_interm(link, ids) for link in node.links()]
+ annotations = [_annotation_to_interm(annot, ids) for annot in node.annotations()]
+
+ return _macro_node(
+ id=ids[node],
+ title=node.title,
+ position=node.position,
+ version="",
+ nodes=nodes,
+ links=links,
+ annotations=annotations,
+ )
+
+
+def _node_to_interm(node: SchemeNode, ids) -> _node:
+ desc = node.description
+ input_defs = output_defs = []
+ if node.input_channels() != desc.inputs:
+ input_defs = node.input_channels()[len(desc.inputs):]
+ if node.output_channels() != desc.outputs:
+ output_defs = node.output_channels()[len(desc.outputs):]
+
+ ttype = lambda s: ",".join(map(str, s))
+ added_inputs = tuple({"name": idef.name, "type": ttype(idef.type)}
+ for idef in input_defs)
+ added_outputs = tuple({"name": odef.name, "type": ttype(odef.type)}
+ for odef in output_defs)
+
+ return _node(
+ id=ids[node],
+ title=node.title,
+ position=node.position,
+ qualified_name=desc.qualified_name,
+ project_name=desc.project_name or "",
+ version=desc.version,
+ added_inputs=added_inputs,
+ added_outputs=added_outputs,
+ data=None,
+ )
+
+
+def _input_node_to_interm(node: InputNode, ids) -> _input_node:
+ return _input_node(
+ id=ids[node],
+ title=node.title,
+ position=node.position,
+ type=node.input_channels()[0].types,
+ multiple=not node.input_channels()[0].single
+ )
+
+
+def _output_node_to_interm(node: OutputNode, ids) -> _output_node:
+ return _output_node(
+ id=ids[node],
+ title=node.title,
+ position=node.position,
+ type=node.output_channels()[0].types,
+ )
+
+
+def _link_to_interm(link: Link, ids):
+ return _link(
+ id=ids[link],
+ enabled=link.enabled,
+ source_node_id=ids[link.source_node],
+ source_channel=link.source_channel.name,
+ source_channel_id=link.source_channel.id,
+ sink_node_id=ids[link.sink_node],
+ sink_channel=link.sink_channel.name,
+ sink_channel_id=link.sink_channel.id,
+ )
+
-def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False):
+def _annotation_to_interm(annot: Annotation, ids) -> _annotation:
+ if isinstance(annot, SchemeTextAnnotation):
+ params = _text_params(
+ geometry=annot.geometry,
+ text=annot.text,
+ content_type=annot.content_type,
+ font={}, # deprecated.
+ )
+ type_ = "text"
+ elif isinstance(annot, SchemeArrowAnnotation):
+ params = _arrow_params(
+ geometry=annot.geometry,
+ color=annot.color
+ )
+ type_ = "arrow"
+ else:
+ raise TypeError()
+
+ return _annotation(
+ id=ids[annot],
+ type=type_,
+ params=params,
+ )
+
+
+def scheme_to_interm(scheme, data_format="literal", pickle_fallback=False):
+ # type: (Scheme, str, bool) -> _scheme
+ """
+ Return a workflow scheme in its intermediate representation for
+ serialization.
+ """
+ node_ids: Dict[Node, str] = {
+ node: str(i + 1) for i, node in enumerate(scheme.all_nodes())
+ }
+ link_ids: Dict[Link, str] = {
+ link: str(i + 1) for i, link in enumerate(scheme.all_links())
+ }
+ annot_ids: Dict[Link, str] = {
+ annot: str(i + 1) for i, annot in enumerate(scheme.all_annotations())
+ }
+ window_presets = []
+ ids = {**node_ids, **link_ids, **annot_ids}
+ # Nodes
+ root = scheme.root()
+ ids[root] = ""
+ iroot = _meta_node_to_interm(root, ids)
+ node_properties = {}
+ for node in root.all_nodes():
+ if node.properties:
+ try:
+ data, format_ = dumps(node.properties, format=data_format,
+ pickle_fallback=pickle_fallback)
+ data = _data(format_, data)
+ except Exception:
+ log.error("Error serializing properties for node %r",
+ node.title, exc_info=True)
+ raise
+ else:
+ data = None
+ node_properties[node_ids[node]] = data
+
+ def fix_props(node: Union[_node, _macro_node, _input_node, _output_node]):
+ if isinstance(node, _macro_node):
+ return node._replace(nodes=[fix_props(n) for n in node.nodes])
+ elif isinstance(node, _node):
+ return node._replace(data=node_properties.get(node.id))
+ else:
+ return node
+ iroot = fix_props(iroot)
+ for preset in scheme.window_group_presets():
+ state = [(node_ids[n], state) for n, state in preset.state]
+ window_presets.append(
+ _window_group(preset.name, preset.default, state)
+ )
+
+ return _scheme(
+ scheme.title, "2.0", scheme.description, iroot.nodes, iroot.links,
+ iroot.annotations, session_state=_session_data(window_presets),
+ )
+
+
+def scheme_to_etree_2_0(scheme, data_format="literal", pickle_fallback=False):
"""
Return an `xml.etree.ElementTree` representation of the `scheme`.
"""
+ scheme = scheme_to_interm(scheme, data_format=data_format,
+ pickle_fallback=pickle_fallback)
builder = TreeBuilder(element_factory=Element)
- builder.start("scheme", {"version": "2.0",
- "title": scheme.title or "",
- "description": scheme.description or ""})
+ builder.start(
+ "scheme", {
+ "version": "2.0",
+ "title": scheme.title,
+ "description": scheme.description,
+ }
+ )
# Nodes
- node_ids = defaultdict(lambda c=itertools.count(): next(c))
builder.start("nodes", {})
- for node in scheme.nodes: # type: SchemeNode
- desc = node.description
- attrs = {"id": str(node_ids[node]),
- "name": desc.name,
- "qualified_name": desc.qualified_name,
- "project_name": desc.project_name or "",
- "version": desc.version or "",
- "title": node.title,
- }
- if node.position is not None:
- attrs["position"] = str(node.position)
-
- if type(node) is not SchemeNode:
- attrs["scheme_node_type"] = "%s.%s" % (type(node).__name__,
- type(node).__module__)
- builder.start("node", attrs)
+ for node in scheme.nodes: # type: _node
+ builder.start(
+ "node", {
+ "id": node.id,
+ # "name": node.name,
+ "qualified_name": node.qualified_name,
+ "project_name": node.project_name,
+ "version": node.version,
+ "title": node.title,
+ "position": node.position,
+ }
+ )
+ for input_def in node.added_inputs:
+ builder.start("added_input", {
+ "name": input_def["name"],
+ "type": input_def["type"],
+ })
+ builder.end("added_input")
+ for output_def in node.added_outputs:
+ builder.start("added_output", {
+ "name": output_def["name"],
+ "type": output_def["type"],
+ })
+ builder.end("added_output")
builder.end("node")
-
builder.end("nodes")
# Links
- link_ids = defaultdict(lambda c=itertools.count(): next(c))
builder.start("links", {})
for link in scheme.links:
- source = link.source_node
- sink = link.sink_node
- source_id = node_ids[source]
- sink_id = node_ids[sink]
- attrs = {"id": str(link_ids[link]),
- "source_node_id": str(source_id),
- "sink_node_id": str(sink_id),
- "source_channel": link.source_channel.name,
- "sink_channel": link.sink_channel.name,
- "enabled": "true" if link.enabled else "false",
- }
- if link.source_channel.id:
- attrs["source_channel_id"] = link.source_channel.id
- if link.sink_channel.id:
- attrs["sink_channel_id"] = link.sink_channel.id
- builder.start("link", attrs)
+ extra = {}
+ if link.source_channel_id:
+ extra["source_channel_id"] = link.source_channel_id
+ if link.sink_channel_id:
+ extra["sink_channel_id"] = link.sink_channel_id
+ builder.start(
+ "link", {
+ "id": link.id,
+ "source_node_id": link.source_node_id,
+ "sink_node_id": link.sink_node_id,
+ "source_channel": link.source_channel,
+ "sink_channel": link.sink_channel,
+ "enabled": "true" if link.enabled else "false",
+ **extra
+ }
+ )
builder.end("link")
-
builder.end("links")
# Annotations
- annotation_ids = defaultdict(lambda c=itertools.count(): next(c))
builder.start("annotations", {})
for annotation in scheme.annotations:
- annot_id = annotation_ids[annotation]
- attrs = {"id": str(annot_id)}
- data = None
- if isinstance(annotation, SchemeTextAnnotation):
- tag = "text"
- attrs.update({"type": annotation.content_type})
- attrs.update({"rect": repr(annotation.rect)})
-
+ attrs = {"id": annotation.id}
+ if annotation.type == "text":
+ params = annotation.params # type: _text_params
+ assert isinstance(params, _text_params)
+ attrs.update({
+ "type": params.content_type,
+ "rect": "{!r}, {!r}, {!r}, {!r}".format(*params.geometry)
+ })
# Save the font attributes
- font = annotation.font
- attrs.update({"font-family": font.get("family", None),
- "font-size": font.get("size", None)})
- attrs = [(key, value) for key, value in attrs.items()
- if value is not None]
- attrs = dict((key, str(value)) for key, value in attrs)
- data = annotation.content
- elif isinstance(annotation, SchemeArrowAnnotation):
- tag = "arrow"
- attrs.update({"start": repr(annotation.start_pos),
- "end": repr(annotation.end_pos),
- "fill": annotation.color})
+ attrs.update({key: str(value) for key, value in params.font.items()
+ if value is not None})
+ data = params.text
+ elif annotation.type == "arrow":
+ params = annotation.params # type: _arrow_params
+ start, end = params.geometry
+ attrs.update({
+ "start": "{!r}, {!r}".format(*start),
+ "end": "{!r}, {!r}".format(*end),
+ "fill": params.color
+ })
data = None
else:
log.warning("Can't save %r", annotation)
continue
- builder.start(tag, attrs)
+ builder.start(annotation.type, attrs)
if data is not None:
builder.data(data)
- builder.end(tag)
+ builder.end(annotation.type)
builder.end("annotations")
- builder.start("thumbnail", {})
- builder.end("thumbnail")
-
# Node properties/settings
builder.start("node_properties", {})
for node in scheme.nodes:
- data = None
- if node.properties:
- try:
- data, format = dumps(node.properties, format=data_format,
- pickle_fallback=pickle_fallback)
- except Exception:
- log.error("Error serializing properties for node %r",
- node.title, exc_info=True)
+ if node.data is not None:
+ data = node.data
+ builder.start(
+ "properties", {
+ "node_id": node.id,
+ "format": data.format
+ }
+ )
+ builder.data(data.data)
+ builder.end("properties")
+
+ builder.end("node_properties")
+ builder.start("session_state", {})
+ builder.start("window_groups", {})
+
+ for g in scheme.session_state.groups: # type: _window_group
+ builder.start(
+ "group", {"name": g.name, "default": str(g.default).lower()}
+ )
+ for node_id, data in g.state:
+ builder.start("window_state", {"node_id": node_id})
+ builder.data(base64.encodebytes(data).decode("ascii"))
+ builder.end("window_state")
+ builder.end("group")
+ builder.end("window_group")
+ builder.end("session_state")
+ builder.end("scheme")
+ root = builder.close()
+ tree = ElementTree(root)
+ return tree
+
+
+# back-compatibility alias
+scheme_to_etree = scheme_to_etree_2_0
+
+
+def scheme_to_etree_3_0(scheme, data_format="literal", pickle_fallback=False):
+ scheme = scheme_to_interm(scheme, data_format=data_format,
+ pickle_fallback=pickle_fallback)
+ builder = TreeBuilder(element_factory=Element)
+ all_nodes = []
+ builder.start(
+ "scheme", {
+ "version": "3.0",
+ "title": scheme.title,
+ "description": scheme.description,
+ }
+ )
+
+ iroot = _macro_node("", "", (0, 0), "", scheme.nodes, scheme.links, scheme.annotations)
+ # Nodes
+
+ def build_node(node: _node):
+ all_nodes.append(node)
+ builder.start(
+ "node", {
+ "id": node.id,
+ # "name": node.name,
+ "qualified_name": node.qualified_name,
+ "project_name": node.project_name,
+ "version": node.version or "",
+ "title": node.title,
+ "position": node.position,
+ }
+ )
+ for input_def in node.added_inputs:
+ builder.start("added_input", {
+ "name": input_def["name"],
+ "type": input_def["type"],
+ })
+ builder.end("added_input")
+ for output_def in node.added_outputs:
+ builder.start("added_output", {
+ "name": output_def["name"],
+ "type": output_def["type"],
+ })
+ builder.end("added_output")
+ builder.end("node")
+
+ def build_input_node(node: _input_node):
+ builder.start(
+ "input_node", {
+ "id": node.id,
+ "title": node.title,
+ "position": f"{node.position[0]}, {node.position[1]}",
+ "type": ",".join(node.type),
+ "multiple": str(node.multiple).lower()
+ }
+ )
+ builder.end("input_node")
+
+ def build_output_node(node: _output_node):
+ builder.start(
+ "output_node", {
+ "id": node.id,
+ "title": node.title,
+ "position": f"{node.position[0]}, {node.position[1]}",
+ "type": ",".join(node.type)
+ }
+ )
+ builder.end("output_node")
+
+ def build_macro_node(node: _macro_node):
+ builder.start(
+ "macro_node", {
+ "id": node.id,
+ "title": node.title,
+ "position": f'{node.position[0]}, {node.position[1]}',
+ "version": "",
+ }
+ )
+ build_macro_node_helper(node)
+ builder.end("macro_node")
+
+ def build_macro_node_helper(node: _macro_node):
+ builder.start("nodes", {})
+ for n in node.nodes:
+ dispatch_node[type(n)](n)
+ builder.end("nodes")
+
+ # Links
+ builder.start("links", {})
+ for link in node.links:
+ builder.start(
+ "link", {
+ "id": link.id,
+ "source_node_id": link.source_node_id,
+ "sink_node_id": link.sink_node_id,
+ "source_channel": link.source_channel,
+ "source_channel_id": link.source_channel_id or "",
+ "sink_channel": link.sink_channel,
+ "sink_channel_id": link.sink_channel_id or "",
+ "enabled": "true" if link.enabled else "false",
+ }
+ )
+ builder.end("link")
+ builder.end("links")
+
+ # Annotations
+ builder.start("annotations", {})
+ for annotation in node.annotations:
+ attrs = {"id": annotation.id}
+ if annotation.type == "text":
+ tag = "text"
+ params = annotation.params # type: _text_params
+ assert isinstance(params, _text_params)
+ attrs.update({
+ "type": params.content_type,
+ "rect": "{!r}, {!r}, {!r}, {!r}".format(*params.geometry)
+ })
+ data = params.text
+ elif annotation.type == "arrow":
+ tag = "arrow"
+ params = annotation.params # type: _arrow_params
+ start, end = params.geometry
+ attrs.update({
+ "start": "{!r}, {!r}".format(*start),
+ "end": "{!r}, {!r}".format(*end),
+ "fill": params.color
+ })
+ data = None
+ else:
+ log.warning("Can't save %r", annotation)
+ continue
+ builder.start(annotation.type, attrs)
if data is not None:
- builder.start("properties",
- {"node_id": str(node_ids[node]),
- "format": format})
builder.data(data)
- builder.end("properties")
+ builder.end(tag)
+
+ builder.end("annotations")
+
+ dispatch_node = {
+ _macro_node: build_macro_node,
+ _node: build_node,
+ _input_node: build_input_node,
+ _output_node: build_output_node,
+ }
+ build_macro_node_helper(iroot)
+ # Node properties/settings
+ builder.start("node_properties", {})
+ for node in all_nodes:
+ if node.data is not None:
+ data = node.data
+ builder.start(
+ "properties", {
+ "node_id": node.id,
+ "format": data.format
+ }
+ )
+ builder.data(data.data)
+ builder.end("properties")
builder.end("node_properties")
builder.start("session_state", {})
builder.start("window_groups", {})
- for g in scheme.window_group_presets():
+ for g in scheme.session_state.groups: # type: _window_group
builder.start(
"group", {"name": g.name, "default": str(g.default).lower()}
)
- for node, data in g.state:
- if node not in node_ids:
- continue
- builder.start("window_state", {"node_id": str(node_ids[node])})
+ for node_id, data in g.state:
+ builder.start("window_state", {"node_id": node_id})
builder.data(base64.encodebytes(data).decode("ascii"))
builder.end("window_state")
builder.end("group")
@@ -711,8 +1350,8 @@ def scheme_to_ows_stream(scheme, stream, pretty=False, pickle_fallback=False):
notation.
"""
- tree = scheme_to_etree(scheme, data_format="literal",
- pickle_fallback=pickle_fallback)
+ tree = scheme_to_etree_3_0(scheme, data_format="literal",
+ pickle_fallback=pickle_fallback)
if pretty:
indent(tree.getroot(), 0)
tree.write(stream, encoding="utf-8", xml_declaration=True)
diff --git a/orangecanvas/scheme/scheme.py b/orangecanvas/scheme/scheme.py
index 183c9600e..672dcae7e 100644
--- a/orangecanvas/scheme/scheme.py
+++ b/orangecanvas/scheme/scheme.py
@@ -3,32 +3,37 @@
Scheme Workflow
===============
-The :class:`Scheme` class defines a DAG (Directed Acyclic Graph) workflow.
-
+The :class:`Scheme` class defines a model for a hierarchical DAG (Directed
+Acyclic Graph) workflow. The :func:`Scheme.root()` is the top level container
+which can contains other :class:`.Node` and :class:`.MetaNode` instances.
"""
import types
import logging
+import warnings
from contextlib import ExitStack
-from operator import itemgetter
-from collections import deque
import typing
-from typing import List, Tuple, Optional, Set, Dict, Any, Mapping
+from typing import List, Tuple, Optional, Set, Dict, Any, Mapping, Sequence
from AnyQt.QtCore import QObject, QCoreApplication
from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property
-from .node import SchemeNode
-from .link import SchemeLink, compatible_channels, _classify_connection
-from .annotations import BaseSchemeAnnotation
-from ..utils import check_arg, findf
+
+from .node import SchemeNode, Node
+from .metanode import (
+ MetaNode, node_dependents, node_dependencies, find_links,
+ all_links_recursive
+)
+from .link import Link, compatible_channels
+from .annotations import Annotation
from .errors import (
SchemeCycleError, IncompatibleChannelTypeError, SinkChannelError,
DuplicatedLinkError
)
-from .events import NodeEvent, LinkEvent, AnnotationEvent, WorkflowEnvChanged
-
+from .events import WorkflowEnvChanged
+from ..utils import unique
+from ..utils.graph import traverse_bf
from ..registry import WidgetDescription, InputSignal, OutputSignal
if typing.TYPE_CHECKING:
@@ -36,15 +41,11 @@
log = logging.getLogger(__name__)
-Node = SchemeNode
-Link = SchemeLink
-Annotation = BaseSchemeAnnotation
-
class Scheme(QObject):
"""
- An :class:`QObject` subclass representing the scheme widget workflow
- with annotations.
+ An :class:`QObject` subclass representing a hierarchical workflow with
+ annotations.
Parameters
----------
@@ -61,31 +62,31 @@ class Scheme(QObject):
NoLoops, AllowLoops, AllowSelfLoops = 0, 1, 2
# Signal emitted when a `node` is added to the scheme.
- node_added = Signal(SchemeNode)
+ node_added = Signal(Node, MetaNode)
# Signal emitted when a `node` is inserted to the scheme.
- node_inserted = Signal(int, Node)
+ node_inserted = Signal(int, Node, MetaNode)
# Signal emitted when a `node` is removed from the scheme.
- node_removed = Signal(SchemeNode)
+ node_removed = Signal(Node, MetaNode)
# Signal emitted when a `link` is added to the scheme.
- link_added = Signal(SchemeLink)
+ link_added = Signal(Link, MetaNode)
# Signal emitted when a `link` is added to the scheme.
- link_inserted = Signal(int, Link)
+ link_inserted = Signal(int, Link, MetaNode)
# Signal emitted when a `link` is removed from the scheme.
- link_removed = Signal(SchemeLink)
+ link_removed = Signal(Link, MetaNode)
# Signal emitted when a `annotation` is added to the scheme.
- annotation_added = Signal(BaseSchemeAnnotation)
+ annotation_added = Signal(Annotation, MetaNode)
# Signal emitted when a `annotation` is added to the scheme.
- annotation_inserted = Signal(int, BaseSchemeAnnotation)
+ annotation_inserted = Signal(int, Annotation, MetaNode)
# Signal emitted when a `annotation` is removed from the scheme.
- annotation_removed = Signal(BaseSchemeAnnotation)
+ annotation_removed = Signal(Annotation, MetaNode)
# Signal emitted when the title of scheme changes.
title_changed = Signal(str)
@@ -107,36 +108,74 @@ def __init__(self, parent=None, title="", description="", env={},
self.__title = title or ""
#: Workflow description (empty string by default).
self.__description = description or ""
- self.__annotations = [] # type: List[BaseSchemeAnnotation]
- self.__nodes = [] # type: List[SchemeNode]
- self.__links = [] # type: List[SchemeLink]
+ self.__root = MetaNode(self.tr("Root"))
+ self.__root._set_workflow(self)
self.__loop_flags = Scheme.NoLoops
self.__env = dict(env) # type: Dict[str, Any]
+ self.__window_group_presets: List['Scheme.WindowGroup'] = []
@property
def nodes(self):
- # type: () -> List[SchemeNode]
+ # type: () -> List[Node]
+ """
+ A list of all nodes (:class:`.Node`) currently in the scheme.
+
+ .. deprecated:: 0.2.0
+ Use `root().nodes()`
+ """
+ warnings.warn("'nodes' is deprecated use 'root().nodes()'",
+ DeprecationWarning, stacklevel=2)
+ return self.__root.nodes()
+
+ def all_nodes(self) -> List[Node]:
"""
- A list of all nodes (:class:`.SchemeNode`) currently in the scheme.
+ Return a list of all subnodes including all subnodes of subnodes.
+
+ Equivalent to `root().all_nodes()`
"""
- return list(self.__nodes)
+ return self.__root.all_nodes()
@property
def links(self):
- # type: () -> List[SchemeLink]
+ # type: () -> List[Link]
"""
- A list of all links (:class:`.SchemeLink`) currently in the scheme.
+ A list of all links (:class:`.Link`) currently in the scheme.
+
+ .. deprecated:: 0.2.0
+ Use `root().links()`
"""
- return list(self.__links)
+ warnings.warn("'links' is deprecated use 'root().links()'",
+ DeprecationWarning, stacklevel=2)
+ return self.__root.links()
+
+ def all_links(self) -> List[Link]:
+ """
+ Return a list of all links including all links in subnodes.
+
+ Equivalent to `root().all_links()`
+ """
+ return self.__root.all_links()
@property
def annotations(self):
- # type: () -> List[BaseSchemeAnnotation]
+ # type: () -> List[Annotation]
+ """
+ A list of all annotations (:class:`.Annotation`) in the scheme.
+
+ .. deprecated:: 0.2.0
+ Use `root().annotations()`
+ """
+ warnings.warn("'annotations' is deprecated use 'root().annotations()'",
+ DeprecationWarning, stacklevel=2)
+ return self.__root.annotations()
+
+ def all_annotations(self):
"""
- A list of all annotations (:class:`.BaseSchemeAnnotation`) in the
- scheme.
+ Return a list of all annotations including all annotations in subnodes.
+
+ Equivalent to `root().all_annotations()`
"""
- return list(self.__annotations)
+ return self.__root.all_annotations()
def set_loop_flags(self, flags):
self.__loop_flags = flags
@@ -151,6 +190,7 @@ def set_title(self, title):
"""
if self.__title != title:
self.__title = title
+ self.__root.set_title(title)
self.title_changed.emit(title)
def _title(self):
@@ -180,35 +220,38 @@ def _description(self):
description: str
description = Property(str, _description, set_description) # type: ignore
- def add_node(self, node):
- # type: (SchemeNode) -> None
+ def root(self) -> MetaNode:
+ """
+ Return the root :class:`MetaNode`.
+
+ This is the top level node containing the whole workflow.
+ """
+ return self.__root
+
+ def add_node(self, node: Node, parent: MetaNode = None) -> None:
"""
Add a node to the scheme. An error is raised if the node is
already in the scheme.
Parameters
----------
- node : :class:`.SchemeNode`
+ node : Node
Node instance to add to the scheme.
-
+ parent: MetaNode
+ An optional meta node into which the node is inserted. If `None` the
+ node is inserted into the `root()` meta node.
"""
- self.insert_node(len(self.__nodes), node)
+ if parent is None:
+ parent = self.__root
+ parent.add_node(node)
- def insert_node(self, index: int, node: Node):
+ def insert_node(self, index: int, node: Node, parent: MetaNode = None):
"""
Insert `node` into self.nodes at the specified position `index`
"""
- assert isinstance(node, SchemeNode)
- check_arg(node not in self.__nodes,
- "Node already in scheme.")
- self.__nodes.insert(index, node)
-
- ev = NodeEvent(NodeEvent.NodeAdded, node, index)
- QCoreApplication.sendEvent(self, ev)
-
- log.info("Added node %r to scheme %r." % (node.title, self.title))
- self.node_added.emit(node)
- self.node_inserted.emit(index, node)
+ if parent is None:
+ parent = self.__root
+ parent.insert_node(index, node)
def new_node(self, description, title=None, position=None,
properties=None):
@@ -238,17 +281,18 @@ def new_node(self, description, title=None, position=None,
"""
if isinstance(description, WidgetDescription):
- node = SchemeNode(description, title=title, position=position,
- properties=properties)
+ node = SchemeNode(
+ description, title=title, position=position or (0, 0),
+ properties=properties)
else:
- raise TypeError("Expected %r, got %r." % \
+ raise TypeError("Expected %r, got %r." %
(WidgetDescription, type(description)))
self.add_node(node)
return node
def remove_node(self, node):
- # type: (SchemeNode) -> SchemeNode
+ # type: (Node) -> Node
"""
Remove a `node` from the scheme. All links into and out of the
`node` are also removed. If the node in not in the scheme an error
@@ -256,102 +300,54 @@ def remove_node(self, node):
Parameters
----------
- node : :class:`.SchemeNode`
+ node : :class:`.Node`
Node instance to remove.
-
"""
- check_arg(node in self.__nodes,
- "Node is not in the scheme.")
-
- self.__remove_node_links(node)
- index = self.__nodes.index(node)
- self.__nodes.pop(index)
- ev = NodeEvent(NodeEvent.NodeRemoved, node, index)
- QCoreApplication.sendEvent(self, ev)
- log.info("Removed node %r from scheme %r." % (node.title, self.title))
- self.node_removed.emit(node)
+ assert node.workflow() is self
+ parent = node.parent_node()
+ assert parent is not None
+ parent.remove_node(node)
return node
- def __remove_node_links(self, node):
- # type: (SchemeNode) -> None
- """
- Remove all links for node.
- """
- links_in, links_out = [], []
- for link in self.__links:
- if link.source_node is node:
- links_out.append(link)
- elif link.sink_node is node:
- links_in.append(link)
-
- for link in links_out + links_in:
- self.remove_link(link)
-
- def insert_link(self, index: int, link: Link):
+ def insert_link(self, index: int, link: Link, parent: MetaNode = None):
"""
Insert `link` into `self.links` at the specified position `index`.
"""
- assert isinstance(link, SchemeLink)
- self.check_connect(link)
- self.__links.insert(index, link)
- source_index, _ = findf(
- enumerate(self.find_links(source_node=link.source_node)),
- lambda t: t[1] == link,
- default=(-1, None)
- )
- sink_index, _ = findf(
- enumerate(self.find_links(sink_node=link.sink_node)),
- lambda t: t[1] == link,
- default=(-1, None)
- )
- assert sink_index != -1 and source_index != -1
- QCoreApplication.sendEvent(
- link.source_node,
- LinkEvent(LinkEvent.OutputLinkAdded, link, source_index)
- )
- QCoreApplication.sendEvent(
- link.sink_node,
- LinkEvent(LinkEvent.InputLinkAdded, link, sink_index)
- )
- QCoreApplication.sendEvent(
- self, LinkEvent(LinkEvent.LinkAdded, link, index)
- )
- log.info("Added link %r (%r) -> %r (%r) to scheme %r." % \
- (link.source_node.title, link.source_channel.name,
- link.sink_node.title, link.sink_channel.name,
- self.title)
- )
- self.link_inserted.emit(index, link)
- self.link_added.emit(link)
+ assert link.workflow() is None, "Link already in a workflow."
+ if parent is None:
+ parent = self.__root
+ parent.insert_link(index, link)
- def add_link(self, link):
- # type: (SchemeLink) -> None
+ def add_link(self, link, parent=None):
+ # type: (Link, MetaNode) -> None
"""
Add a `link` to the scheme.
Parameters
----------
- link : :class:`.SchemeLink`
+ link : :class:`.Link`
An initialized link instance to add to the scheme.
"""
- self.insert_link(len(self.__links), link)
+ if parent is None:
+ parent = self.__root
+ parent.add_link(link)
def new_link(self, source_node, source_channel,
sink_node, sink_channel):
- # type: (SchemeNode, OutputSignal, SchemeNode, InputSignal) -> SchemeLink
+ # type: (Node, OutputSignal, Node, InputSignal) -> Link
"""
- Create a new :class:`.SchemeLink` from arguments and add it to
+ Create a new :class:`.Link` from arguments and add it to
the scheme. The new link is returned.
Parameters
----------
- source_node : :class:`.SchemeNode`
+ source_node : :class:`.Node`
Source node of the new link.
source_channel : :class:`.OutputSignal`
Source channel of the new node. The instance must be from
``source_node.output_channels()``
- sink_node : :class:`.SchemeNode`
+ sink_node : :class:`.Node`
Sink node of the new link.
sink_channel : :class:`.InputSignal`
Sink channel of the new node. The instance must be from
@@ -359,60 +355,31 @@ def new_link(self, source_node, source_channel,
See also
--------
- .SchemeLink, Scheme.add_link
+ .Link, Scheme.add_link
"""
- link = SchemeLink(source_node, source_channel,
- sink_node, sink_channel)
+ link = Link(source_node, source_channel, sink_node, sink_channel)
self.add_link(link)
return link
def remove_link(self, link):
- # type: (SchemeLink) -> None
+ # type: (Link) -> None
"""
Remove a link from the scheme.
Parameters
----------
- link : :class:`.SchemeLink`
+ link : :class:`.Link`
Link instance to remove.
"""
- check_arg(link in self.__links,
- "Link is not in the scheme.")
- source_index, _ = findf(
- enumerate(self.find_links(source_node=link.source_node)),
- lambda t: t[1] == link,
- default=(-1, None)
- )
- sink_index, _ = findf(
- enumerate(self.find_links(sink_node=link.sink_node)),
- lambda t: t[1] == link,
- default=(-1, None)
- )
- assert sink_index != -1 and source_index != -1
- index = self.__links.index(link)
- self.__links.pop(index)
- QCoreApplication.sendEvent(
- link.sink_node,
- LinkEvent(LinkEvent.InputLinkRemoved, link, sink_index)
- )
- QCoreApplication.sendEvent(
- link.source_node,
- LinkEvent(LinkEvent.OutputLinkRemoved, link, source_index)
- )
- QCoreApplication.sendEvent(
- self, LinkEvent(LinkEvent.LinkRemoved, link, index)
- )
- log.info("Removed link %r (%r) -> %r (%r) from scheme %r." % \
- (link.source_node.title, link.source_channel.name,
- link.sink_node.title, link.sink_channel.name,
- self.title)
- )
- self.link_removed.emit(link)
+ assert link.workflow() is self
+ parent = link.parent_node()
+ assert parent is not None
+ parent.remove_link(link)
def check_connect(self, link):
- # type: (SchemeLink) -> None
+ # type: (Link) -> None
"""
Check if the `link` can be added to the scheme and raise an
appropriate exception.
@@ -462,34 +429,34 @@ def check_connect(self, link):
)
def creates_cycle(self, link):
- # type: (SchemeLink) -> bool
+ # type: (Link) -> bool
"""
Return `True` if `link` would introduce a cycle in the scheme.
Parameters
----------
- link : :class:`.SchemeLink`
+ link : :class:`.Link`
"""
- assert isinstance(link, SchemeLink)
+ assert isinstance(link, Link)
source_node, sink_node = link.source_node, link.sink_node
upstream = self.upstream_nodes(source_node)
upstream.add(source_node)
return sink_node in upstream
def compatible_channels(self, link):
- # type: (SchemeLink) -> bool
+ # type: (Link) -> bool
"""
Return `True` if the channels in `link` have compatible types.
Parameters
----------
- link : :class:`.SchemeLink`
+ link : :class:`.Link`
"""
- assert isinstance(link, SchemeLink)
+ assert isinstance(link, Link)
return compatible_channels(link.source_channel, link.sink_channel)
def can_connect(self, link):
- # type: (SchemeLink) -> bool
+ # type: (Link) -> bool
"""
Return `True` if `link` can be added to the scheme.
@@ -498,7 +465,7 @@ def can_connect(self, link):
Scheme.check_connect
"""
- assert isinstance(link, SchemeLink)
+ assert isinstance(link, Link)
try:
self.check_connect(link)
return True
@@ -507,119 +474,108 @@ def can_connect(self, link):
return False
def upstream_nodes(self, start_node):
- # type: (SchemeNode) -> Set[SchemeNode]
+ # type: (Node) -> Set[Node]
"""
Return a set of all nodes upstream from `start_node` (i.e.
all ancestor nodes).
Parameters
----------
- start_node : :class:`.SchemeNode`
-
+ start_node : :class:`.Node`
"""
- visited = set() # type: Set[SchemeNode]
- queue = deque([start_node])
- while queue:
- node = queue.popleft()
- snodes = [link.source_node for link in self.input_links(node)]
- for source_node in snodes:
- if source_node not in visited:
- queue.append(source_node)
-
- visited.add(node)
- visited.remove(start_node)
- return visited
+ return set(traverse_bf(start_node, node_dependencies))
def downstream_nodes(self, start_node):
- # type: (SchemeNode) -> Set[SchemeNode]
+ # type: (Node) -> Set[Node]
"""
Return a set of all nodes downstream from `start_node`.
Parameters
----------
- start_node : :class:`.SchemeNode`
-
+ start_node : :class:`.Node`
"""
- visited = set() # type: Set[SchemeNode]
- queue = deque([start_node])
- while queue:
- node = queue.popleft()
- snodes = [link.sink_node for link in self.output_links(node)]
- for source_node in snodes:
- if source_node not in visited:
- queue.append(source_node)
-
- visited.add(node)
- visited.remove(start_node)
- return visited
+ return set(traverse_bf(start_node, node_dependents))
def is_ancestor(self, node, child):
- # type: (SchemeNode, SchemeNode) -> bool
+ # type: (Node, Node) -> bool
"""
Return True if `node` is an ancestor node of `child` (is upstream
of the child in the workflow). Both nodes must be in the scheme.
Parameters
----------
- node : :class:`.SchemeNode`
- child : :class:`.SchemeNode`
-
+ node : :class:`.Node`
+ child : :class:`.Node`
"""
return child in self.downstream_nodes(node)
- def children(self, node):
- # type: (SchemeNode) -> Set[SchemeNode]
+ def children(self, node): # type: (Node) -> Set[Node]
+ """
+ .. deprecated:: 0.2.0
+ Use `child_nodes()`
+ """
+ warnings.warn("'children()' is deprecated, use 'child_nodes()'",
+ DeprecationWarning, stacklevel=2)
+ return set(self.child_nodes(node))
+
+ def child_nodes(self, node: Node) -> Sequence[Node]:
+ """
+ Return all immediate descendant nodes of `node`.
+ """
+ return list(unique(link.sink_node for link in self.output_links(node)))
+
+ def parents(self, node): # type: (Node) -> Set[Node]
"""
- Return a set of all children of `node`.
+ .. deprecated:: 0.2.0
+ Use parent_nodes()
"""
- return set(link.sink_node for link in self.output_links(node))
+ warnings.warn("'parents()' is deprecated, use 'parent_nodes()'",
+ DeprecationWarning, stacklevel=2)
+ return set(self.parent_nodes(node))
- def parents(self, node):
- # type: (SchemeNode) -> Set[SchemeNode]
+ def parent_nodes(self, node: Node) -> Sequence[Node]:
"""
- Return a set of all parents of `node`.
+ Return all immediate ancestor nodes of `node`.
"""
- return set(link.source_node for link in self.input_links(node))
+ return list(unique(link.source_node for link in self.input_links(node)))
def input_links(self, node):
- # type: (SchemeNode) -> List[SchemeLink]
+ # type: (Node) -> List[Link]
"""
- Return a list of all input links (:class:`.SchemeLink`) connected
+ Return a list of all input links (:class:`.Link`) connected
to the `node` instance.
"""
return self.find_links(sink_node=node)
def output_links(self, node):
- # type: (SchemeNode) -> List[SchemeLink]
+ # type: (Node) -> List[Link]
"""
- Return a list of all output links (:class:`.SchemeLink`) connected
+ Return a list of all output links (:class:`.Link`) connected
to the `node` instance.
"""
return self.find_links(source_node=node)
- def find_links(self, source_node=None, source_channel=None,
- sink_node=None, sink_channel=None):
- # type: (Optional[SchemeNode], Optional[OutputSignal], Optional[SchemeNode], Optional[InputSignal]) -> List[SchemeLink]
- # TODO: Speedup - keep index of links by nodes and channels
- result = []
-
- def match(query, value):
- # type: (Optional[T], T) -> bool
- return query is None or value == query
-
- for link in self.__links:
- if match(source_node, link.source_node) and \
- match(sink_node, link.sink_node) and \
- match(source_channel, link.source_channel) and \
- match(sink_channel, link.sink_channel):
- result.append(link)
-
- return result
+ def find_links(
+ self,
+ source_node: Optional[Node] = None,
+ source_channel: Optional[OutputSignal] = None,
+ sink_node: Optional[Node] = None,
+ sink_channel: Optional[InputSignal] = None
+ ) -> List[Link]:
+ """
+ Find links in this workflow that match the specified
+ {source,sink}_{node,channel} arguments (if `None` any will match).
+ """
+ return find_links(
+ all_links_recursive(self.__root),
+ source_node=source_node, source_channel=source_channel,
+ sink_node=sink_node, sink_channel=sink_channel
+ )
def propose_links(
self,
- source_node: SchemeNode,
- sink_node: SchemeNode,
+ source_node: Node,
+ sink_node: Node,
source_signal: Optional[OutputSignal] = None,
sink_signal: Optional[InputSignal] = None
) -> List[Tuple[OutputSignal, InputSignal, int]]:
@@ -630,100 +586,53 @@ def propose_links(
.. note:: This can depend on the links already in the scheme.
+ .. deprecated:: 0.2.0
+ Use :func:`orangecanvas.document.interactions.propose_links`
"""
- if source_node is sink_node and \
- not self.loop_flags() & Scheme.AllowSelfLoops:
- # Self loops are not enabled
- return []
+ from orangecanvas.document.interactions import propose_links
+ warnings.warn("'propose_links' is deprecated use "
+ "'orangecanvas.document.interactions.propose_links'",
+ DeprecationWarning, stacklevel=2)
+ return propose_links(
+ self, source_node, sink_node, source_signal, sink_signal
+ )
- elif not self.loop_flags() & Scheme.AllowLoops and \
- self.is_ancestor(sink_node, source_node):
- # Loops are not enabled.
- return []
-
- outputs = [source_signal] if source_signal \
- else source_node.output_channels()
- inputs = [sink_signal] if sink_signal \
- else sink_node.input_channels()
-
- # Get existing links to sink channels that are Single.
- links = self.find_links(None, None, sink_node)
- already_connected_sinks = [link.sink_channel for link in links \
- if link.sink_channel.single]
-
- def weight(out_c, in_c):
- # type: (OutputSignal, InputSignal) -> int
- if out_c.explicit or in_c.explicit:
- weight = -1 # Negative weight for explicit links
- else:
- check = [in_c not in already_connected_sinks,
- bool(in_c.default),
- bool(out_c.default)]
- weights = [2 ** i for i in range(len(check), 0, -1)]
- weight = sum([w for w, c in zip(weights, check) if c])
- return weight
-
- proposed_links = []
- for out_c in outputs:
- for in_c in inputs:
- if compatible_channels(out_c, in_c):
- proposed_links.append((out_c, in_c, weight(out_c, in_c)))
-
- return sorted(proposed_links, key=itemgetter(-1), reverse=True)
-
- def insert_annotation(self, index: int, annotation: Annotation) -> None:
- """
- Insert `annotation` into `self.annotations` at the specified
- position `index`.
- """
- assert isinstance(annotation, BaseSchemeAnnotation)
- if annotation in self.__annotations:
- raise ValueError("Cannot add the same annotation multiple times")
- self.__annotations.insert(index, annotation)
- ev = AnnotationEvent(AnnotationEvent.AnnotationAdded,
- annotation, index)
- QCoreApplication.sendEvent(self, ev)
- self.annotation_inserted.emit(index, annotation)
- self.annotation_added.emit(annotation)
-
- def add_annotation(self, annotation):
- # type: (BaseSchemeAnnotation) -> None
- """
- Add an annotation (:class:`BaseSchemeAnnotation` subclass) instance
- to the scheme.
- """
- self.insert_annotation(len(self.__annotations), annotation)
+ def insert_annotation(self, index: int, annotation: Annotation, parent: MetaNode = None) -> None:
+ """
+ Insert `annotation` into `parent` at the specified position `index`.
+
+ If `parent` is `None` then insert into `self.root()`
+ """
+ if parent is None:
+ parent = self.__root
+ parent.insert_annotation(index, annotation)
+
+ def add_annotation(self, annotation: Annotation, parent: MetaNode = None) -> None:
+ """
+ Add an annotation (:class:`Annotation` subclass) instance to `parent`.
+
+ If `parent` is `None` then insert into `self.root()`
+ """
+ if parent is None:
+ parent = self.__root
+ parent.add_annotation(annotation)
def remove_annotation(self, annotation):
- # type: (BaseSchemeAnnotation) -> None
+ # type: (Annotation) -> None
"""
Remove the `annotation` instance from the scheme.
"""
- index = self.__annotations.index(annotation)
- self.__annotations.pop(index)
- ev = AnnotationEvent(AnnotationEvent.AnnotationRemoved,
- annotation, index)
- QCoreApplication.sendEvent(self, ev)
- self.annotation_removed.emit(annotation)
+ assert annotation.workflow() is self
+ parent = annotation.parent_node()
+ assert parent is not None
+ parent.remove_annotation(annotation)
def clear(self):
# type: () -> None
"""
Remove all nodes, links, and annotation items from the scheme.
"""
- def is_terminal(node):
- # type: (SchemeNode) -> bool
- return not bool(self.find_links(source_node=node))
-
- while self.nodes:
- terminal_nodes = filter(is_terminal, self.nodes)
- for node in terminal_nodes:
- self.remove_node(node)
-
- for annotation in self.annotations:
- self.remove_annotation(annotation)
-
- assert not (self.nodes or self.links or self.annotations)
+ self.__root.clear()
def sync_node_properties(self):
# type: () -> None
@@ -731,7 +640,6 @@ def sync_node_properties(self):
Called before saving, allowing a subclass to update/sync.
The default implementation does nothing.
-
"""
pass
@@ -759,7 +667,8 @@ def load_from(self, stream, *args, **kwargs):
--------
readwrite.scheme_load
"""
- if self.__nodes or self.__links or self.__annotations:
+ root = self.__root
+ if root.nodes() or root.links() or root.links():
raise ValueError("Scheme is not empty.")
with ExitStack() as exitstack:
@@ -815,11 +724,11 @@ def window_group_presets(self):
The base implementation returns an empty list.
"""
- return self.property("_presets") or []
+ return list(self.__window_group_presets)
def set_window_group_presets(self, groups):
# type: (List[WindowGroup]) -> None
- self.setProperty("_presets", groups)
+ self.__window_group_presets = list(groups)
self.window_group_presets_changed.emit()
diff --git a/orangecanvas/scheme/signalmanager.py b/orangecanvas/scheme/signalmanager.py
index 3e3020dca..cacec90aa 100644
--- a/orangecanvas/scheme/signalmanager.py
+++ b/orangecanvas/scheme/signalmanager.py
@@ -28,9 +28,12 @@
from AnyQt.QtCore import pyqtSignal, pyqtSlot as Slot
from . import LinkEvent
-from ..utils import unique, mapping_get, group_by_all
+from ..utils import unique, mapping_get, group_by_all, apply_all
from ..registry import OutputSignal, InputSignal
-from .scheme import Scheme, SchemeNode, SchemeLink
+from .scheme import Scheme
+from .node import Node, SchemeNode
+from .link import Link
+from .metanode import MetaNode, InputNode, OutputNode
from ..utils.graph import traverse_bf, strongly_connected_components
if typing.TYPE_CHECKING:
@@ -43,7 +46,7 @@
class Signal(
NamedTuple(
"Signal", (
- ("link", SchemeLink),
+ ("link", Link),
("value", Any),
("id", Any),
("index", int),
@@ -54,7 +57,7 @@ class Signal(
Attributes
----------
- link : SchemeLink
+ link : Link
The link on which the signal is sent
value : Any
The signal value
@@ -69,7 +72,7 @@ class Signal(
--------
InputSignal.flags, OutputSignal.flags
"""
- def __new__(cls, link: SchemeLink, value: Any, id: Any = None,
+ def __new__(cls, link: Link, value: Any, id: Any = None,
index: int = -1):
return super().__new__(cls, link, value, id, index)
@@ -81,6 +84,7 @@ def channel(self) -> InputSignal:
New: 'Type[New]'
Update: 'Type[Update]'
Close: 'Type[Close]'
+ Payload: 'Type[MultiLinkPayload]'
class New(Signal): ...
@@ -93,6 +97,21 @@ class Close(Signal): ...
Signal.Close = Close
+class MultiLinkPayload(NamedTuple):
+ link: Link
+ value: Any
+ id: Any = None
+ index: int = -1
+ payload: Signal = None
+
+ @property
+ def channel(self):
+ return self.link.sink_channel
+
+
+Signal.Payload = MultiLinkPayload
+
+
is_enabled = attrgetter("enabled")
@@ -329,14 +348,17 @@ class RuntimeState(enum.IntEnum):
#: emitting `finished`.
started = pyqtSignal()
- def __init__(self, parent=None, *, max_running=None, **kwargs):
- # type: (Optional[QObject], Optional[int], Any) -> None
+ def __init__(
+ self, parent: Optional[QObject] = None, *,
+ max_running: Optional[int] = None,
+ **kwargs
+ ) -> None:
super().__init__(parent, **kwargs)
self.__workflow = None # type: Optional[Scheme]
self.__input_queue = [] # type: List[Signal]
# mapping a node to its current outputs
- self.__node_outputs = {} # type: Dict[SchemeNode, DefaultDict[OutputSignal, _OutputState]]
+ self.__node_outputs = {} # type: Dict[Node, DefaultDict[OutputSignal, _OutputState]]
#: Extra link state
self.__link_extra = defaultdict(_LinkExtra) # type: DefaultDict[SchemeLink, _LinkExtra]
@@ -380,10 +402,11 @@ def set_workflow(self, workflow):
return
if self.__workflow is not None:
- for node in self.__workflow.nodes:
+ root = self.__workflow.root()
+ for node in root.all_nodes():
node.state_changed.disconnect(self._update)
node.removeEventFilter(self)
- for link in self.__workflow.links:
+ for link in root.all_links():
self.__on_link_removed(link)
self.__workflow.node_added.disconnect(self.__on_node_added)
@@ -401,12 +424,12 @@ def set_workflow(self, workflow):
workflow.node_removed.connect(self.__on_node_removed)
workflow.link_added.connect(self.__on_link_added)
workflow.link_removed.connect(self.__on_link_removed)
- for node in workflow.nodes:
+ for node in workflow.all_nodes():
self.__node_outputs[node] = defaultdict(_OutputState)
node.state_changed.connect(self._update)
node.installEventFilter(self)
- for link in workflow.links:
+ for link in workflow.all_links():
self.__on_link_added(link)
workflow.installEventFilter(self)
@@ -506,7 +529,7 @@ def runtime_state(self):
return self.__runtime_state
def __on_node_removed(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
# remove all pending input signals for node so we don't get
# stale references in process_node.
# NOTE: This does not remove output signals for this node. In
@@ -518,25 +541,39 @@ def __on_node_removed(self, node):
del self.__node_outputs[node]
node.state_changed.disconnect(self._update)
node.removeEventFilter(self)
+ if isinstance(node, MetaNode):
+ apply_all(self.__on_node_removed, node.nodes())
+ apply_all(self.__on_link_removed, node.links())
def __on_node_added(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
self.__node_outputs[node] = defaultdict(_OutputState)
+
# schedule update pass on state change
node.state_changed.connect(self._update)
node.installEventFilter(self)
+ if isinstance(node, MetaNode):
+ apply_all(self.__on_node_added, node.all_nodes())
+ apply_all(self.__on_link_added, node.all_links())
def __on_link_added(self, link):
- # type: (SchemeLink) -> None
+ # type: (Link) -> None
# push all current source values to the sink
- link.set_runtime_state(SchemeLink.Empty)
+ link.set_runtime_state(Link.Empty)
state = self.__node_outputs[link.source_node][link.source_channel]
link.set_runtime_state_flag(
- SchemeLink.Invalidated,
+ Link.Invalidated,
bool(state.flags & _OutputState.Invalidated)
)
signals: List[Signal] = [Signal.New(*s)
for s in self.signals_on_link(link)]
+ if isinstance(link.source_node, InputNode) and not link.source_channel.single:
+ signals = [
+ Signal.Payload(
+ link, sig.value, sig.id, sig.index, payload=sig
+ )
+ for sig in signals
+ ]
if not link.is_enabled():
# Send New signals even if disabled. This is changed behaviour
# from <0.1.19 where signals were only sent when link was enabled.
@@ -548,7 +585,7 @@ def __on_link_added(self, link):
link.enabled_changed.connect(self.__on_link_enabled_changed)
def __on_link_removed(self, link):
- # type: (SchemeLink) -> None
+ # type: (Link) -> None
link.enabled_changed.disconnect(self.__on_link_enabled_changed)
self.__link_extra.pop(link, None)
@@ -557,9 +594,21 @@ def eventFilter(self, recv: QObject, event: QEvent) -> bool:
if etype == LinkEvent.InputLinkRemoved:
event = typing.cast(LinkEvent, event)
link = event.link()
+ signals: List[Signal] = []
log.info("Scheduling close signal (%s).", link)
- signals: List[Signal] = [Signal.Close(link, None, id, event.pos())
- for id in self.link_contents(link)]
+ source = link.source_node
+ if isinstance(source, InputNode) and not source.sink_channel.single:
+ # close multi payload link
+ contents = self.link_contents(link)
+ signals = [ # type: ignore
+ Signal.Payload(
+ link, None, id_, event.pos(),
+ payload=Signal.Close(link, None, (link_, id_), event.pos()))
+ for (link_, id_), _ in contents.items()
+ ]
+ else:
+ signals = [Signal.Close(link, None, id, event.pos())
+ for id in self.link_contents(link)]
self._schedule(signals)
return super().eventFilter(recv, event)
@@ -570,7 +619,7 @@ def __on_link_enabled_changed(self, enabled):
self._update_link(link)
def signals_on_link(self, link):
- # type: (SchemeLink) -> List[Signal]
+ # type: (Link) -> List[Signal]
"""
Return :class:`Signal` instances representing the current values
present on the `link`.
@@ -584,7 +633,7 @@ def signals_on_link(self, link):
for key, value in items.items()]
def link_contents(self, link):
- # type: (SchemeLink) -> Dict[Any, Any]
+ # type: (Link) -> Dict[Any, Any]
"""
Return the contents on the `link`.
"""
@@ -600,8 +649,8 @@ def link_contents(self, link):
if sig.link is link]
return {sig.id: sig.value for sig in pending}
- def send(self, node, channel, value, *args, **kwargs):
- # type: (SchemeNode, OutputSignal, Any, Any, Any) -> None
+ def send(self, node, channel, value):
+ # type: (SchemeNode, OutputSignal, Any) -> None
"""
Send the `value` on the output `channel` from `node`.
@@ -615,48 +664,20 @@ def send(self, node, channel, value, *args, **kwargs):
The nodes output on which the value is sent.
value : Any
The value to send,
- id : Any
- Signal id.
-
- .. deprecated:: 0.1.19
"""
- if self.__workflow is None:
- raise RuntimeError("'send' called with no workflow!.")
-
- # parse deprecated id parameter from *args, **kwargs.
- def _id_(id):
- return id
- try:
- id = _id_(*args, **kwargs)
- except TypeError:
- id = None
- else:
- warnings.warn(
- "`id` parameter is deprecated and will be removed in v0.2",
- FutureWarning, stacklevel=2
- )
-
- log.debug("%r sending %r (id: %r) on channel %r",
- node.title, type(value), id, channel.name)
-
scheme = self.__workflow
-
+ if scheme is None:
+ raise RuntimeError("'send' called with no workflow!.")
+ log.debug("%r sending %r on channel %r",
+ node.title, type(value), channel.name)
state = self.__node_outputs[node][channel]
-
- if state.outputs and id not in state.outputs:
- raise RuntimeError(
- "Sending multiple values on the same output channel via "
- "different ids is no longer supported."
- )
-
sigtype: Type[Signal]
- if id in state.outputs:
+ if state.outputs:
sigtype = Signal.Update
else:
sigtype = Signal.New
-
- state.outputs[id] = value
+ state.outputs[None] = value
assert len(state.outputs) == 1
# clear invalidated flag
if state.flags & _OutputState.Invalidated:
@@ -668,18 +689,18 @@ def _id_(id):
signals = []
for link in links:
extra = self.__link_extra[link]
- links_in = scheme.find_links(sink_node=link.sink_node)
+ links_in = scheme.input_links(link.sink_node)
index = links_in.index(link)
if not link.is_enabled() and not extra.flags & _LinkExtra.DidScheduleNew:
# Send Signal.New with None value. Proper update will be done
# when/if the link is re-enabled.
- signal = Signal.New(link, None, id, index=index)
+ signal = Signal.New(link, None, index=index)
elif link.is_enabled():
- signal = sigtype(link, value, id, index=index)
+ signal = sigtype(link, value, index=index)
else:
continue
signals.append(signal)
- link.set_runtime_state_flag(SchemeLink.Invalidated, False)
+ link.set_runtime_state_flag(Link.Invalidated, False)
self._schedule(signals)
@@ -693,7 +714,7 @@ def invalidate(self, node, channel):
nodes will not be updated.
All links originating with this node/channel will be marked with
- `SchemeLink.Invalidated` flag until a new value is sent with `send`.
+ `Link.Invalidated` flag until a new value is sent with `send`.
Parameters
----------
@@ -716,7 +737,7 @@ def invalidate(self, node, channel):
link.set_runtime_state(link.runtime_state() | link.Invalidated)
def purge_link(self, link):
- # type: (SchemeLink) -> None
+ # type: (Link) -> None
"""
Purge the link (send None for all ids currently present)
@@ -741,17 +762,17 @@ def _schedule(self, signals):
extra.flags |= _LinkExtra.DidScheduleNew
for link in {sig.link for sig in signals}:
- # update the SchemeLink's runtime state flags
+ # update the Link's runtime state flags
contents = self.link_contents(link)
if any(value is not None for value in contents.values()):
- state = SchemeLink.Active
+ state = Link.Active
else:
- state = SchemeLink.Empty
- link.set_runtime_state(state | SchemeLink.Pending)
+ state = Link.Empty
+ link.set_runtime_state(state | Link.Pending)
- for node in {sig.link.sink_node for sig in signals}: # type: SchemeNode
- # update the SchemeNodes's runtime state flags
- node.set_state_flags(SchemeNode.Pending, True)
+ for node in {sig.link.sink_node for sig in signals}: # type: Node
+ # update the Nodes's runtime state flags
+ node.set_state_flags(Node.Pending, True)
if signals:
self.updatesPending.emit()
@@ -759,7 +780,7 @@ def _schedule(self, signals):
self._update()
def _update_link(self, link):
- # type: (SchemeLink) -> None
+ # type: (Link) -> None
"""
Schedule update of a single link.
"""
@@ -799,7 +820,7 @@ def process_next(self):
return self.__process_next_helper(use_max_active=False)
def process_node(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
"""
Process pending input signals for `node`.
"""
@@ -814,7 +835,7 @@ def process_node(self, node):
node.title, len(signals_in))
# Clear the link's pending flag.
for link in {sig.link for sig in signals_in}:
- link.set_runtime_state(link.runtime_state() & ~SchemeLink.Pending)
+ link.set_runtime_state(link.runtime_state() & ~Link.Pending)
def process_dynamic(signals):
# type: (List[Signal]) -> List[Signal]
@@ -838,17 +859,95 @@ def process_dynamic(signals):
signals_in = process_dynamic(signals_in)
assert ({sig.link for sig in self.__input_queue}
.intersection({sig.link for sig in signals_in}) == set([]))
+ signals_in = [sig.payload if isinstance(sig, Signal.Payload) else sig
+ for sig in signals_in]
+ if isinstance(node, SchemeNode):
+ # implementation for concrete node implementations
+ self._set_runtime_state(SignalManager.Processing)
+ self.processingStarted.emit()
+ self.processingStarted[SchemeNode].emit(node)
+ try:
+ self.send_to_node(node, signals_in)
+ finally:
+ node.set_state_flags(SchemeNode.Pending, False)
+ self.processingFinished.emit()
+ self.processingFinished[SchemeNode].emit(node)
+ self._set_runtime_state(SignalManager.Waiting)
+ else:
+ self.__send_to_node(node, signals_in)
+
+ def __send_to_node(self, node: Node, signals: Sequence[Signal]):
+ if isinstance(node, MetaNode):
+ self.__send_to_meta_node(node, signals)
+ elif isinstance(node, OutputNode):
+ self.__send_to_output_node(node, signals)
+ else:
+ assert False
- self._set_runtime_state(SignalManager.Processing)
- self.processingStarted.emit()
- self.processingStarted[SchemeNode].emit(node)
- try:
- self.send_to_node(node, signals_in)
- finally:
- node.set_state_flags(SchemeNode.Pending, False)
- self.processingFinished.emit()
- self.processingFinished[SchemeNode].emit(node)
- self._set_runtime_state(SignalManager.Waiting)
+ def __send_to_meta_node(self, node: MetaNode, signals: Sequence[Signal]):
+ # Re-dispatch signals to macro input nodes.
+ workflow = self.__workflow
+ assert workflow is not None
+ input_nodes = {channel: node.node_for_input_channel(channel)
+ for channel in node.input_channels()}
+ links = {
+ channel: workflow.find_links(
+ source_node=inode, source_channel=inode.output_channels()[0]
+ )
+ for channel, inode in input_nodes.items()
+ }
+ signals_ = []
+ for sig in signals:
+ inode = input_nodes[sig.channel]
+ outputs = self.__node_outputs[inode][inode.source_channel].outputs
+ if not sig.channel.single:
+ sig_id = (sig.link, sig.id)
+ else:
+ sig_id = sig.id
+ sigtype = Signal.New if sig_id not in outputs else Signal.Update
+ outputs[sig_id] = sig.value
+ if isinstance(sig, Signal.Close):
+ del outputs[sig_id]
+ for link in links[sig.channel]:
+ if not sig.channel.single:
+ sig_ = Signal.Payload(
+ link, value=sig.value, id=sig_id,
+ index=sig.index, # TODO: fix index
+ payload=sigtype(
+ link=link, value=sig.value, id=sig_id,
+ index=link_index(link)
+ )
+ )
+ else:
+ sig_ = sigtype(
+ link=link, value=sig.value, id=sig_id,
+ index=link_index(link)
+ )
+ signals_.append(sig_)
+
+ self._schedule(signals_)
+ self.__update_timer.start(0)
+
+ def __send_to_output_node(self, node: OutputNode, signals: Sequence[Signal]):
+ # These are just redispatched
+ workflow = self.__workflow
+ root = node.parent_node()
+ assert workflow is not None and root is not None
+ channel = node.output_channels()[0]
+ assert root.node_for_output_channel(channel) is node
+ links = workflow.find_links(source_node=root, source_channel=channel)
+ signals_ = []
+ for sig in signals:
+ outputs = self.__node_outputs[root][channel].outputs
+ sigtype = Signal.New if sig.id not in outputs else Signal.Update
+ outputs[sig.id] = sig.value
+ for link in links:
+ sig = sigtype(
+ link=link, value=sig.value, id=sig.id,
+ index=link_index(link)
+ )
+ signals_.append(sig)
+ self._schedule(signals_)
def compress_signals(self, signals):
# type: (List[Signal]) -> List[Signal]
@@ -890,14 +989,14 @@ def send_to_node(self, node, signals):
raise NotImplementedError
def is_pending(self, node):
- # type: (SchemeNode) -> bool
+ # type: (Node) -> bool
"""
- Is `node` (class:`SchemeNode`) scheduled for processing (i.e.
+ Is `node` (class:`Node`) scheduled for processing (i.e.
it has incoming pending signals).
Parameters
----------
- node : SchemeNode
+ node : Node
Returns
-------
@@ -906,7 +1005,7 @@ def is_pending(self, node):
return node in [signal.link.sink_node for signal in self.__input_queue]
def pending_nodes(self):
- # type: () -> List[SchemeNode]
+ # type: () -> List[Node]
"""
Return a list of pending nodes.
@@ -915,12 +1014,12 @@ def pending_nodes(self):
Returns
-------
- nodes : List[SchemeNode]
+ nodes : List[Node]
"""
return list(unique(sig.link.sink_node for sig in self.__input_queue))
def pending_input_signals(self, node):
- # type: (SchemeNode) -> List[Signal]
+ # type: (Node) -> List[Signal]
"""
Return a list of pending input signals for node.
"""
@@ -928,7 +1027,7 @@ def pending_input_signals(self, node):
if node is signal.link.sink_node]
def remove_pending_signals(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
"""
Remove pending signals for `node`.
"""
@@ -939,18 +1038,18 @@ def remove_pending_signals(self, node):
pass
def __nodes(self):
- # type: () -> Sequence[SchemeNode]
- return self.__workflow.nodes if self.__workflow else []
+ # type: () -> Sequence[Node]
+ return self.__workflow.all_nodes() if self.__workflow else []
def blocking_nodes(self):
- # type: () -> List[SchemeNode]
+ # type: () -> List[Node]
"""
Return a list of nodes in a blocking state.
"""
- return [node for node in self.__nodes() if self.is_blocking(node)]
+ return [node for node in self.__nodes() if self.__is_blocking(node)]
def invalidated_nodes(self):
- # type: () -> List[SchemeNode]
+ # type: () -> List[Node]
"""
Return a list of invalidated nodes.
@@ -958,7 +1057,7 @@ def invalidated_nodes(self):
"""
return [node for node in self.__nodes()
if self.has_invalidated_outputs(node) or
- self.is_invalidated(node)]
+ self.__is_invalidated(node)]
def active_nodes(self):
# type: () -> List[SchemeNode]
@@ -967,7 +1066,13 @@ def active_nodes(self):
.. versionadded:: 0.1.8
"""
- return [node for node in self.__nodes() if self.is_active(node)]
+ return [node for node in self.__nodes() if self.__is_active(node)]
+
+ def __is_blocking(self, node: Node) -> bool:
+ if isinstance(node, SchemeNode):
+ return self.is_blocking(node)
+ else:
+ return False # macro/input/output nodes are not blocking
def is_blocking(self, node):
# type: (SchemeNode) -> bool
@@ -986,6 +1091,12 @@ def is_blocking(self, node):
"""
return False
+ def __is_ready(self, node: Node) -> bool:
+ if isinstance(node, SchemeNode):
+ return self.is_ready(node)
+ else:
+ return True # macro/input/output nodes are always ready
+
def is_ready(self, node: SchemeNode) -> bool:
"""
Is the node in a state where it can receive inputs.
@@ -1004,7 +1115,7 @@ def is_ready(self, node: SchemeNode) -> bool:
----------
node: SchemeNode
"""
- return not node.test_state_flags(SchemeNode.NotReady)
+ return not node.test_state_flags(Node.NotReady)
def is_invalidated(self, node: SchemeNode) -> bool:
"""
@@ -1018,7 +1129,13 @@ def is_invalidated(self, node: SchemeNode) -> bool:
-------
state: bool
"""
- return node.test_state_flags(SchemeNode.Invalidated)
+ return node.test_state_flags(Node.Invalidated)
+
+ def __is_invalidated(self, node: Node) -> bool:
+ if isinstance(node, SchemeNode):
+ return self.is_invalidated(node)
+ else:
+ return False # macro/input/outputs are not invalidated
def has_invalidated_outputs(self, node):
# type: (SchemeNode) -> bool
@@ -1071,9 +1188,15 @@ def has_invalidated_inputs(self, node):
return False
workflow = self.__workflow
return any(self.has_invalidated_outputs(link.source_node)
- for link in workflow.find_links(sink_node=node)
+ for link in workflow.input_links(node)
if link.is_enabled())
+ def __is_active(self, node: Node) -> bool:
+ if isinstance(node, SchemeNode):
+ return self.is_active(node)
+ else:
+ return False # macro/input/ouput nodes do not count as active
+
def is_active(self, node):
# type: (SchemeNode) -> bool
"""
@@ -1087,10 +1210,10 @@ def is_active(self, node):
-------
active: bool
"""
- return bool(node.state() & SchemeNode.Running)
+ return bool(node.state() & Node.Running)
def node_update_front(self):
- # type: () -> Sequence[SchemeNode]
+ # type: () -> Sequence[Node]
"""
Return a list of nodes on the update front, i.e. nodes scheduled for
an update that have no ancestor which is either itself scheduled
@@ -1103,15 +1226,16 @@ def node_update_front(self):
if self.__workflow is None:
return []
workflow = self.__workflow
+ root = workflow.root()
expand = partial(expand_node, workflow)
- components = strongly_connected_components(workflow.nodes, expand)
+ components = strongly_connected_components(root.all_nodes(), expand)
node_scc = {node: scc for scc in components for node in scc}
- def isincycle(node): # type: (SchemeNode) -> bool
+ def isincycle(node): # type: (Node) -> bool
return len(node_scc[node]) > 1
- def dependents(node): # type: (SchemeNode) -> List[SchemeNode]
+ def dependents(node): # type: (Node) -> List[Node]
return dependent_nodes(workflow, node)
# A list of all nodes currently active/executing a non-interruptable
@@ -1127,9 +1251,11 @@ def dependents(node): # type: (SchemeNode) -> List[SchemeNode]
set.union,
map(dependents, invalidated_nodes | blocking_nodes),
set([]),
- ) # type: Set[SchemeNode]
+ ) # type: Set[Node]
pending = self.pending_nodes()
+ if set(pending) - set(root.all_nodes()):
+ warnings.warn("Stale nodes in pending list", RuntimeWarning)
pending_ = set()
for n in pending:
depend = set(dependents(n))
@@ -1141,17 +1267,17 @@ def dependents(node): # type: (SchemeNode) -> List[SchemeNode]
depend -= set(cc)
pending_.update(depend)
- def has_invalidated_ancestor(node): # type: (SchemeNode) -> bool
+ def has_invalidated_ancestor(node): # type: (Node) -> bool
return node in invalidated_
- def has_pending_ancestor(node): # type: (SchemeNode) -> bool
+ def has_pending_ancestor(node): # type: (Node) -> bool
return node in pending_
#: nodes that are eligible for update.
ready = list(filter(
lambda node: not has_pending_ancestor(node)
and not has_invalidated_ancestor(node)
- and not self.is_blocking(node),
+ and not self.__is_blocking(node),
pending
))
return ready
@@ -1182,8 +1308,9 @@ def __process_next(self):
self._update()
def __process_next_helper(self, use_max_active=True) -> bool:
- eligible = [n for n in self.node_update_front() if self.is_ready(n)]
+ eligible = [n for n in self.node_update_front() if self.__is_ready(n)]
if not eligible:
+ log.debug("No eligible nodes")
return False
max_active = self.max_active()
nactive = len(set(self.active_nodes()) | set(self.blocking_nodes()))
@@ -1201,9 +1328,9 @@ def __process_next_helper(self, use_max_active=True) -> bool:
# Select an node that is already running (effectively cancelling
# already executing tasks that are immediately updatable)
- selected_node = None # type: Optional[SchemeNode]
+ selected_node = None # type: Optional[Node]
for node in eligible:
- if self.is_active(node):
+ if self.__is_active(node):
selected_node = node
break
@@ -1275,9 +1402,9 @@ def max_active(self) -> int:
def can_enable_dynamic(link, value):
- # type: (SchemeLink, Any) -> bool
+ # type: (Link, Any) -> bool
"""
- Can the a dynamic `link` (:class:`SchemeLink`) be enabled for`value`.
+ Can the a dynamic `link` (:class:`Link`) be enabled for`value`.
"""
if LazyValue.is_lazy(value):
value = value.get_value()
@@ -1371,6 +1498,16 @@ def is_close(signal: 'Optional[Signal]') -> bool:
return out
+def link_index(link: Link) -> int:
+ workflow = link.workflow()
+ if workflow is None:
+ return -1
+ links = workflow.find_links(
+ sink_node=link.sink_node, sink_channel=link.sink_channel
+ )
+ return links.index(link)
+
+
def expand_node(workflow, node):
# type: (Scheme, SchemeNode) -> List[SchemeNode]
return [link.sink_node
diff --git a/orangecanvas/scheme/tests/test_nodes.py b/orangecanvas/scheme/tests/test_nodes.py
index 047248b60..eb032b41a 100644
--- a/orangecanvas/scheme/tests/test_nodes.py
+++ b/orangecanvas/scheme/tests/test_nodes.py
@@ -1,5 +1,6 @@
"""
"""
+from AnyQt.QtTest import QSignalSpy
from ...gui import test
from ...registry.tests import small_testing_registry
@@ -47,3 +48,43 @@ def test_channels_by_name_or_id(self):
self.assertIs(node.input_channel("right"), add_desc.inputs[1])
self.assertIs(node.input_channel("droite"), add_desc.inputs[1])
self.assertRaises(ValueError, node.input_channel, "gauche")
+
+ def test_insert_remove_io(self):
+ reg = small_testing_registry()
+ node = SchemeNode(reg.widget("add"))
+ inserted = QSignalSpy(node.input_channel_inserted)
+ removed = QSignalSpy(node.input_channel_removed)
+ input = InputSignal("input", "int", "")
+
+ with self.assertRaises(IndexError):
+ node.insert_input_channel(0, input)
+ node.insert_input_channel(2, input)
+ self.assertSequenceEqual(list(inserted), [[2, input]])
+ self.assertSequenceEqual(
+ node.input_channels(), [*node.description.inputs, input]
+ )
+
+ with self.assertRaises(IndexError):
+ node.remove_input_channel(0)
+
+ node.remove_input_channel(2)
+ self.assertSequenceEqual(list(removed), [[2, input]])
+ self.assertSequenceEqual(node.input_channels(), node.description.inputs)
+
+ inserted = QSignalSpy(node.output_channel_inserted)
+ removed = QSignalSpy(node.output_channel_removed)
+
+ output = OutputSignal("a", "int")
+ with self.assertRaises(IndexError):
+ node.insert_output_channel(0, output)
+
+ node.insert_output_channel(1, output)
+ self.assertSequenceEqual(list(inserted), [[1, output]])
+ self.assertSequenceEqual(
+ node.output_channels(), [*node.description.outputs, output]
+ )
+ with self.assertRaises(IndexError):
+ node.remove_output_channel(0)
+
+ node.remove_output_channel(1)
+ self.assertSequenceEqual(list(removed), [[1, output]])
diff --git a/orangecanvas/scheme/tests/test_readwrite.py b/orangecanvas/scheme/tests/test_readwrite.py
index e300bae43..7dcf14ccd 100644
--- a/orangecanvas/scheme/tests/test_readwrite.py
+++ b/orangecanvas/scheme/tests/test_readwrite.py
@@ -5,11 +5,12 @@
from xml.etree import ElementTree as ET
from ...gui import test
-from ...registry import WidgetRegistry, WidgetDescription, CategoryDescription
+from ...registry import WidgetRegistry, WidgetDescription, CategoryDescription, \
+ InputSignal, OutputSignal
from ...registry import tests as registry_tests
from .. import Scheme, SchemeNode, SchemeLink, \
- SchemeArrowAnnotation, SchemeTextAnnotation
+ SchemeArrowAnnotation, SchemeTextAnnotation, MetaNode
from .. import readwrite
@@ -48,17 +49,17 @@ def test_io(self):
scheme_1 = readwrite.scheme_load(Scheme(), stream, reg)
- self.assertTrue(len(scheme.nodes) == len(scheme_1.nodes))
- self.assertTrue(len(scheme.links) == len(scheme_1.links))
- self.assertTrue(len(scheme.annotations) == len(scheme_1.annotations))
+ self.assertEqual(len(scheme.all_nodes()), len(scheme_1.all_nodes()))
+ self.assertEqual(len(scheme.all_links()), len(scheme_1.all_links()))
+ self.assertEqual(len(scheme.all_annotations()), len(scheme_1.all_annotations()))
- for n1, n2 in zip(scheme.nodes, scheme_1.nodes):
+ for n1, n2 in zip(scheme.all_nodes(), scheme_1.all_nodes()):
self.assertEqual(n1.position, n2.position)
self.assertEqual(n1.title, n2.title)
- for link1, link2 in zip(scheme.links, scheme_1.links):
- self.assertEqual(link1.source_type(), link2.source_type())
- self.assertEqual(link1.sink_type(), link2.sink_type())
+ for link1, link2 in zip(scheme.all_links(), scheme_1.all_links()):
+ self.assertEqual(link1.source_types(), link2.source_types())
+ self.assertEqual(link1.sink_types(), link2.sink_types())
self.assertEqual(link1.source_channel.name,
link2.source_channel.name)
@@ -68,7 +69,7 @@ def test_io(self):
self.assertEqual(link1.enabled, link2.enabled)
- for annot1, annot2 in zip(scheme.annotations, scheme_1.annotations):
+ for annot1, annot2 in zip(scheme.all_annotations(), scheme_1.all_annotations()):
self.assertIs(type(annot1), type(annot2))
if isinstance(annot1, SchemeTextAnnotation):
self.assertEqual(annot1.text, annot2.text)
@@ -149,6 +150,97 @@ def test_resolve_replaced(self):
projects = [node.project_name for node in parsed.nodes]
self.assertSetEqual(set(projects), set(["Foo", "Bar"]))
+ def test_dynamic_io_channels(self):
+ reg = foo_registry()
+ scheme = Scheme()
+ node = SchemeNode(reg.widget("frob.bar"))
+ scheme.add_node(node)
+ node.add_input_channel(InputSignal("a", "int", ""))
+ node.add_input_channel(InputSignal("b", "str", ""))
+
+ node.add_output_channel(OutputSignal("a", "int",))
+ node.add_output_channel(OutputSignal("b", "str"))
+
+ stream = io.BytesIO()
+ readwrite.scheme_to_ows_stream(scheme, stream, )
+ stream.seek(0)
+ scheme_1 = Scheme()
+ readwrite.scheme_load(scheme_1, stream, reg)
+ node_1 = scheme_1.root().nodes()[0]
+ self.assertEqual(node_1.input_channels()[-1].name, "b")
+ self.assertEqual(node_1.output_channels()[-1].name, "b")
+
+ def test_macro(self):
+ tree = ET.parse(io.BytesIO(FOOBAR_v30.encode()))
+ root = readwrite.parse_ows_etree_v_3_0(tree)
+ self.assertEqual(len(root.nodes), 3)
+ self.assertEqual(len(root.links), 2)
+ macro = root.nodes[2]
+ self.assertIsInstance(macro, readwrite._macro_node)
+ self.assertEqual(len(macro.nodes), 3)
+ self.assertEqual(len(macro.links), 2)
+ self.assertEqual(len(macro.annotations), 1)
+
+ workflow = Scheme()
+ reg = foo_registry()
+ workflow.load_from(io.BytesIO(FOOBAR_v30.encode()), registry=reg)
+ root = workflow.root()
+ macro = root.nodes()[2]
+ self.assertIsInstance(macro, MetaNode)
+ self.assertEqual(len(macro.nodes()), 3)
+ self.assertEqual(len(macro.links()), 2)
+ self.assertEqual(len(macro.annotations()), 1)
+
+ stream = io.BytesIO()
+ workflow.save_to(stream, )
+ workflow.clear()
+ stream.seek(0)
+ workflow.load_from(stream, registry=reg)
+
+ root = workflow.root()
+ macro = root.nodes()[2]
+ self.assertIsInstance(macro, MetaNode)
+ self.assertEqual(len(macro.nodes()), 3)
+ self.assertEqual(len(macro.links()), 2)
+ self.assertEqual(len(macro.annotations()), 1)
+
+ def test_properties_restore(self):
+ def load(stream):
+ wf = Scheme()
+ reg = foo_registry()
+ readwrite.scheme_load(wf, stream, reg)
+ return wf
+
+ workflow = load(io.BytesIO(FOOBAR_v30.encode()))
+ root = workflow.root()
+ nodes = root.nodes()
+ n1, n2 = nodes[0], nodes[1]
+ self.assertEqual(n1.properties, {"id": 1})
+ self.assertEqual(n2.properties, {"id": 2})
+ meta = nodes[2]
+ assert isinstance(meta, MetaNode)
+ n6 = meta.nodes()[2]
+ assert n6.properties == {"id": 6}
+
+ n6.properties = {"id": 6, "x": "1"}
+ n2.properties = {"id": 2, "x": "2"}
+ n1.properties = {"id": 1, "x": "3"}
+
+ buffer = io.BytesIO()
+ readwrite.scheme_to_ows_stream(workflow, buffer, pretty=True)
+ buffer.seek(0)
+ workflow = load(buffer)
+
+ root = workflow.root()
+ nodes = root.nodes()
+ n1, n2 = nodes[0], nodes[1]
+ self.assertEqual(n1.properties, {"id": 1, "x": "3"})
+ self.assertEqual(n2.properties, {"id": 2, "x": "2"})
+ meta = nodes[2]
+ assert isinstance(meta, MetaNode)
+ n6 = meta.nodes()[2]
+ self.assertEqual(n6.properties, {"id": 6, "x": "1"})
+
def foo_registry():
reg = WidgetRegistry()
@@ -160,6 +252,8 @@ def foo_registry():
qualified_name="package.foo",
project_name="Foo",
category="Quack",
+ inputs=[InputSignal("foo", object,)],
+ outputs=[OutputSignal("foo", object)],
)
)
reg.register_widget(
@@ -170,27 +264,13 @@ def foo_registry():
project_name="Bar",
replaces=["package.bar"],
category="Quack",
+ inputs=[InputSignal("bar", object, )],
+ outputs=[OutputSignal("bar", object)],
)
)
return reg
-FOOBAR_v10 = """
-
-
-
-
-
-
-
-
-
-
-
-"""
-
FOOBAR_v20 = """
@@ -207,3 +287,44 @@ def foo_registry():
"""
+
+FOOBAR_v30 = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This is a Baz
+
+
+
+
+
+
+
+
+ {"id": 1}
+ {"id": 2}
+ {"id": 6}
+
+
+"""
diff --git a/orangecanvas/scheme/tests/test_scheme.py b/orangecanvas/scheme/tests/test_scheme.py
index e8e657806..7210a1acc 100644
--- a/orangecanvas/scheme/tests/test_scheme.py
+++ b/orangecanvas/scheme/tests/test_scheme.py
@@ -5,11 +5,12 @@
from ...gui import test
from ...registry.tests import small_testing_registry
+from ...registry import InputSignal
from .. import (
Scheme, SchemeNode, SchemeLink, SchemeTextAnnotation,
SchemeArrowAnnotation, SchemeTopologyError, SinkChannelError,
- DuplicatedLinkError, IncompatibleChannelTypeError
+ DuplicatedLinkError, IncompatibleChannelTypeError, MetaNode, Link, Text
)
@@ -101,27 +102,28 @@ def test_scheme(self):
text_annot = SchemeTextAnnotation((0, 0, 100, 20), "Text")
scheme.add_annotation(text_annot)
self.assertSequenceEqual(annotations_added, [text_annot])
- self.assertSequenceEqual(scheme.annotations, annotations_added)
+ self.assertSequenceEqual(scheme.root().annotations(), annotations_added)
arrow_annot = SchemeArrowAnnotation((0, 100), (100, 100))
scheme.add_annotation(arrow_annot)
self.assertSequenceEqual(annotations_added, [text_annot, arrow_annot])
- self.assertSequenceEqual(scheme.annotations, annotations_added)
+ self.assertSequenceEqual(scheme.root().annotations(), annotations_added)
scheme.remove_annotation(text_annot)
self.assertSequenceEqual(annotations_added, [arrow_annot])
- self.assertSequenceEqual(scheme.annotations, annotations_added)
+ self.assertSequenceEqual(scheme.root().annotations(), annotations_added)
def test_insert_node(self):
reg = small_testing_registry()
one_desc = reg.widget("one")
n1, n2 = SchemeNode(one_desc), SchemeNode(one_desc)
w = Scheme()
+ r = w.root()
spy = QSignalSpy(w.node_inserted)
w.add_node(n1)
w.insert_node(0, n2)
- self.assertSequenceEqual(list(spy), [[0, n1], [0, n2]])
- self.assertSequenceEqual(w.nodes, [n2, n1])
+ self.assertSequenceEqual(list(spy), [[0, n1, r], [0, n2, r]])
+ self.assertSequenceEqual(w.root().nodes(), [n2, n1])
def test_insert_link(self):
reg = small_testing_registry()
@@ -129,6 +131,7 @@ def test_insert_link(self):
add_desc = reg.widget("add")
n1, n2, n3 = SchemeNode(one_desc), SchemeNode(one_desc), SchemeNode(add_desc)
w = Scheme()
+ r = w.root()
spy = QSignalSpy(w.link_inserted)
w.add_node(n1)
w.add_node(n2)
@@ -137,11 +140,12 @@ def test_insert_link(self):
l2 = SchemeLink(n2, "value", n3, "right")
w.add_link(l1)
w.insert_link(0, l2)
- self.assertSequenceEqual(list(spy), [[0, l1], [0, l2]])
- self.assertSequenceEqual(w.links, [l2, l1])
+ self.assertSequenceEqual(list(spy), [[0, l1, r], [0, l2, r]])
+ self.assertSequenceEqual(w.root().links(), [l2, l1])
def test_insert_annotation(self):
w = Scheme()
+ r = w.root()
a1 = SchemeTextAnnotation((0, 0, 1, 1), "a1")
a2 = SchemeTextAnnotation((0, 0, 1, 1), "a2")
a3 = SchemeTextAnnotation((0, 0, 1, 1), "a3")
@@ -149,5 +153,40 @@ def test_insert_annotation(self):
w.insert_annotation(0, a1)
w.insert_annotation(1, a2)
w.insert_annotation(0, a3)
- self.assertSequenceEqual(w.annotations, [a3, a1, a2])
- self.assertSequenceEqual(list(spy), [[0, a1], [1, a2], [0, a3]])
+ self.assertSequenceEqual(w.root().annotations(), [a3, a1, a2])
+ self.assertSequenceEqual(list(spy), [[0, a1, r], [1, a2, r], [0, a3, r]])
+
+ def test_meta_nodes(self):
+ w = Scheme()
+ reg = small_testing_registry()
+ one_desc = reg.widget("one")
+ add_desc = reg.widget("add")
+ neg_desc = reg.widget("negate")
+ n1, n2, n3 = SchemeNode(one_desc), SchemeNode(one_desc), SchemeNode(add_desc)
+ macro = MetaNode("Plus One")
+ w.add_node(macro)
+ self.assertIs(macro.parent_node(), w.root())
+ w.add_node(n1)
+ macro.add_node(n2)
+ macro.add_node(n3)
+ macro.add_link(Link(n2, "value", n3, "right"))
+ input = InputSignal("value", int, "-")
+ nodein = macro.create_input_node(input)
+ macro.add_link(Link(nodein, nodein.output_channels()[0], n3, "left"))
+ nodeout = macro.create_output_node(add_desc.outputs[0])
+ macro.add_link(Link(n3, n3.output_channels()[0],
+ nodeout, nodeout.input_channels()[0]))
+ macro.add_annotation(Text((0, 0, 200, 200), "Add one"), )
+ w.add_link(Link(n1, "value", macro, "value"))
+
+ n4 = SchemeNode(neg_desc)
+ w.add_node(n4)
+ w.add_link(Link(macro, "result", n4, "value"))
+ self.assertIn(n4, w.downstream_nodes(n1))
+ self.assertIn(n3, w.downstream_nodes(n1))
+ self.assertIn(n4, w.downstream_nodes(n2))
+
+ self.assertIn(n2, w.upstream_nodes(n4))
+ self.assertIn(n3, w.upstream_nodes(n4))
+ self.assertIn(n1, w.upstream_nodes(n4))
+ w.clear()
diff --git a/orangecanvas/scheme/tests/test_signalmanager.py b/orangecanvas/scheme/tests/test_signalmanager.py
index ce945d855..564c16390 100644
--- a/orangecanvas/scheme/tests/test_signalmanager.py
+++ b/orangecanvas/scheme/tests/test_signalmanager.py
@@ -106,6 +106,7 @@ def setUp(self):
def test(self):
workflow = self.scheme
+ root = workflow.root()
sm = TestingSignalManager()
sm.set_workflow(workflow)
sm.set_workflow(workflow)
@@ -118,7 +119,7 @@ def test(self):
sm.stop()
sm.pause()
sm.resume()
- n0, n1, n3 = workflow.nodes
+ n0, n1, n3 = root.nodes()
sm.send(n0, n0.description.outputs[0], 'hello')
sm.send(n1, n1.description.outputs[0], 'hello')
@@ -129,7 +130,7 @@ def test(self):
self.assertEqual(n3.property("-input-right"), 'hello')
self.assertFalse(sm.has_pending())
- workflow.remove_link(workflow.links[0])
+ workflow.remove_link(root.links()[0])
self.assertTrue(sm.has_pending())
spy = QSignalSpy(sm.processingFinished[SchemeNode])
@@ -141,8 +142,8 @@ def test(self):
def test_add_link_disabled(self):
workflow = self.scheme
sm = self.signal_manager
- n0, n1, n2 = workflow.nodes
- l0, l1 = workflow.links
+ n0, n1, n2 = workflow.root().nodes()
+ l0, l1 = workflow.root().links()
workflow.remove_link(l0)
sm.send(n0, n0.description.outputs[0], 1)
sm.send(n1, n1.description.outputs[0], 2)
@@ -184,8 +185,8 @@ def test_link_new_dispatch_after_enable(self):
def test_invalidated_flags(self):
workflow = self.scheme
sm = self.signal_manager
- n0, n1, n2 = workflow.nodes[:3]
- l0, l1 = workflow.links[:2]
+ n0, n1, n2 = workflow.root().nodes()[:3]
+ l0, l1 = workflow.root().links()[:2]
self.assertFalse(l0.runtime_state() & SchemeLink.Invalidated)
sm.send(n0, n0.description.outputs[0], 'hello')
@@ -230,8 +231,8 @@ def test_invalidated_flags(self):
def test_pending_flags(self):
workflow = self.scheme
sm = self.signal_manager
- n0, n1, n3 = workflow.nodes[:3]
- l0, l1 = workflow.links[:2]
+ n0, n1, n3 = workflow.root().nodes()[:3]
+ l0, l1 = workflow.root().links()[:2]
self.assertFalse(n3.test_state_flags(SchemeNode.Pending))
self.assertFalse(l0.runtime_state() & SchemeLink.Pending)
@@ -248,7 +249,7 @@ def test_pending_flags(self):
def test_ready_flags(self):
workflow = self.scheme
sm = self.signal_manager
- n0, n1, n3 = workflow.nodes[:3]
+ n0, n1, n3 = workflow.root().nodes()[:3]
sm.send(n0, n0.output_channel("value"), 'hello')
sm.send(n1, n1.output_channel("value"), 'hello')
self.assertIn(n3, sm.node_update_front())
@@ -263,7 +264,7 @@ def test_ready_flags(self):
def test_start_finished(self):
workflow = self.scheme
sm = self.signal_manager
- n0, n1, n3 = workflow.nodes[:3]
+ n0, n1, n3 = workflow.root().nodes()[:3]
start_spy = QSignalSpy(sm.started)
fin_spy = QSignalSpy(sm.finished)
sm.send(n0, n0.output_channel("value"), 'hello')
@@ -278,7 +279,7 @@ def test_start_finished(self):
def test_compress_signals(self):
workflow = self.scheme
- link = workflow.links[0]
+ link = workflow.root().links()[0]
self.assertSequenceEqual(compress_signals([]), [])
signals_in = [
Signal(link, 1, None),
@@ -328,7 +329,7 @@ def test_compress_signals(self):
def test_compress_signals_single(self):
New, Update, Close = Signal.New, Signal.Update, Signal.Close
workflow = self.scheme
- link = workflow.links[0]
+ link = workflow.root().links()[0]
self.assertSequenceEqual(
compress_single([]), []
)
@@ -420,7 +421,8 @@ def test_compress_signals_single(self):
)
def test_compress_signals_typed(self):
- l1, l2 = self.scheme.links[0], self.scheme.links[1]
+ links = self.scheme.root().links()
+ l1, l2 = links[0], links[1]
New, Update, Close = Signal.New, Signal.Update, Signal.Close
signals = [
New(l1, 1, index=0),
diff --git a/orangecanvas/scheme/tests/test_widgetmanager.py b/orangecanvas/scheme/tests/test_widgetmanager.py
index 1b5143a4d..c78008d4c 100644
--- a/orangecanvas/scheme/tests/test_widgetmanager.py
+++ b/orangecanvas/scheme/tests/test_widgetmanager.py
@@ -52,6 +52,12 @@ def setUp(self):
scheme.new_link(one, "value", add, "right")
self.scheme = scheme
+ self.root = scheme.root()
+
+ def tearDown(self) -> None:
+ del self.scheme
+ del self.root
+ super().tearDown()
def tearDown(self) -> None:
self.scheme.clear()
@@ -63,7 +69,7 @@ def test_create_immediate(self):
wm.set_creation_policy(TestingWidgetManager.Immediate)
spy = QSignalSpy(wm.widget_for_node_added)
wm.set_workflow(self.scheme)
- nodes = self.scheme.nodes
+ nodes = self.root.nodes()
self.assertEqual(len(spy), 3)
self.assertSetEqual({n for n, _ in spy}, set(nodes))
spy = QSignalSpy(wm.widget_for_node_removed)
@@ -73,7 +79,7 @@ def test_create_immediate(self):
def test_create_normal(self):
workflow = self.scheme
- nodes = workflow.nodes
+ nodes = workflow.root().nodes()
wm = TestingWidgetManager()
wm.set_creation_policy(TestingWidgetManager.Normal)
spy = QSignalSpy(wm.widget_for_node_added)
@@ -98,7 +104,7 @@ def test_create_normal(self):
def test_create_on_demand(self):
workflow = self.scheme
- nodes = workflow.nodes
+ nodes = workflow.root().nodes()
wm = TestingWidgetManager()
wm.set_creation_policy(WidgetManager.OnDemand)
spy = QSignalSpy(wm.widget_for_node_added)
@@ -116,7 +122,7 @@ def test_create_on_demand(self):
def test_mappings(self):
workflow = self.scheme
- nodes = workflow.nodes
+ nodes = workflow.root().nodes()
wm = TestingWidgetManager()
wm.set_workflow(workflow)
w = wm.widget_for_node(nodes[0])
@@ -125,7 +131,7 @@ def test_mappings(self):
def test_save_geometry(self):
workflow = self.scheme
- nodes = workflow.nodes
+ nodes = workflow.root().nodes()
wm = TestingWidgetManager()
wm.set_workflow(workflow)
n = nodes[0]
@@ -154,9 +160,9 @@ def test_set_model(self):
wm.set_workflow(Scheme())
def test_event_dispatch(self):
- workflow = self.scheme
- nodes = workflow.nodes
- links = workflow.links
+ workflow, root = self.scheme, self.root
+ nodes = root.nodes()
+ links = root.links()
class Widget(QWidget):
def __init__(self, *a):
@@ -212,7 +218,7 @@ def assertInWithCount(self, member, container, expected):
def test_activation_on_delayed_creation_policy(self):
workflow = self.scheme
- nodes = workflow.nodes
+ nodes = workflow.root().nodes()
wm = TestingWidgetManager()
wm.set_creation_policy(WidgetManager.Normal)
wm.set_workflow(workflow)
@@ -255,7 +261,7 @@ def action_descendants(widget: QWidget) -> QAction:
return widget.findChild(QAction, "action-canvas-raise-descendants")
workflow = self.scheme
- nodes = workflow.nodes
+ nodes = workflow.root().nodes()
wm = TestingWidgetManager()
wm.set_creation_policy(WidgetManager.Immediate)
wm.set_workflow(workflow)
diff --git a/orangecanvas/scheme/widgetmanager.py b/orangecanvas/scheme/widgetmanager.py
index 470210a60..6dd8165f9 100644
--- a/orangecanvas/scheme/widgetmanager.py
+++ b/orangecanvas/scheme/widgetmanager.py
@@ -16,8 +16,7 @@
from AnyQt.QtGui import QKeySequence
from AnyQt.QtWidgets import QWidget, QLabel, QAction
-from orangecanvas.resources import icon_loader
-from orangecanvas.scheme import SchemeNode, Scheme, NodeEvent, LinkEvent, Link
+from orangecanvas.scheme import SchemeNode, Scheme, NodeEvent, LinkEvent, Link, MetaNode
from orangecanvas.scheme.events import WorkflowEvent
from orangecanvas.scheme.node import UserMessage
from orangecanvas.gui.windowlistmanager import WindowListManager
@@ -133,7 +132,7 @@ def set_workflow(self, workflow):
if self.__workflow is not None:
# cleanup
- for node in self.__workflow.nodes:
+ for node in self.__workflow.all_nodes():
self.__remove_node(node)
self.__workflow.node_added.disconnect(self.__on_node_added)
self.__workflow.node_removed.disconnect(self.__on_node_removed)
@@ -146,7 +145,7 @@ def set_workflow(self, workflow):
workflow.node_removed.connect(
self.__on_node_removed, Qt.UniqueConnection)
workflow.installEventFilter(self)
- for node in workflow.nodes:
+ for node in workflow.all_nodes():
self.__add_node(node)
def workflow(self):
@@ -167,7 +166,7 @@ def set_creation_policy(self, policy):
self.__init_timer.stop()
# create all
if self.__workflow is not None:
- for node in self.__workflow.nodes:
+ for node in self.__workflow.all_nodes():
self.ensure_created(node)
elif self.__creation_policy == WidgetManager.Normal:
if not self.__init_timer.isActive() and self.__init_queue:
@@ -229,7 +228,7 @@ def __add_widget_for_node(self, node):
if self.__workflow is None:
return
- if node not in self.__workflow.nodes:
+ if node not in self.__workflow.all_nodes():
return
if node in self.__init_queue:
@@ -262,10 +261,7 @@ def __add_widget_for_node(self, node):
self.__set_float_on_top_flag(w)
if w.windowIcon().isNull():
- desc = node.description
- w.setWindowIcon(
- icon_loader.from_description(desc).get(desc.icon)
- )
+ w.setWindowIcon(node.icon())
if not w.windowTitle():
w.setWindowTitle(node.title)
@@ -276,7 +272,9 @@ def __add_widget_for_node(self, node):
toolTip=self.tr("Raise containing canvas workflow window"),
shortcut=QKeySequence("Ctrl+Up")
)
- raise_canvas.triggered.connect(self.__on_activate_parent)
+ raise_canvas.triggered.connect(
+ partial(self.__on_activate_parent, node)
+ )
raise_descendants = QAction(
self.tr("Raise Descendants"), w,
objectName="action-canvas-raise-descendants",
@@ -323,6 +321,8 @@ def removeWindowAction(_, a: QAction):
# send all the post creation notification events
workflow = self.__workflow
assert workflow is not None
+ ev = NodeEvent(NodeEvent.NodeAdded, node)
+ QCoreApplication.sendEvent(w, ev)
inputs = workflow.find_links(sink_node=node)
raise_ancestors.setEnabled(bool(inputs))
for i, link in enumerate(inputs):
@@ -343,18 +343,25 @@ def ensure_created(self, node):
"""
if self.__workflow is None:
return
- if node not in self.__workflow.nodes:
+ # ignore MetaNodes and co.
+ if not isinstance(node, SchemeNode):
+ return
+ if node not in self.__workflow.all_nodes():
return
item = self.__item_for_node.get(node)
if item is None:
self.__add_widget_for_node(node)
def __on_node_added(self, node):
- # type: (SchemeNode) -> None
+ # type: (Node) -> None
assert self.__workflow is not None
- assert node in self.__workflow.nodes
- assert node not in self.__item_for_node
- self.__add_node(node)
+ if isinstance(node, MetaNode):
+ nodes = node.all_nodes()
+ else:
+ nodes = [node]
+ for n in nodes:
+ if isinstance(n, SchemeNode):
+ self.__add_node(n)
def __add_node(self, node):
# type: (SchemeNode) -> None
@@ -369,8 +376,13 @@ def __add_node(self, node):
def __on_node_removed(self, node): # type: (SchemeNode) -> None
assert self.__workflow is not None
- assert node not in self.__workflow.nodes
- self.__remove_node(node)
+ assert node not in self.__workflow.all_nodes()
+ if isinstance(node, MetaNode):
+ nodes = node.all_nodes()
+ else:
+ nodes = [node]
+ for n in nodes:
+ self.__remove_node(n)
def __remove_node(self, node): # type: (SchemeNode) -> None
# remove the node and its widget from tracking.
@@ -386,6 +398,8 @@ def __remove_node(self, node): # type: (SchemeNode) -> None
widget.removeEventFilter(self.__activation_monitor)
windowmanager = WindowListManager.instance()
windowmanager.removeWindow(widget)
+ ev = NodeEvent(NodeEvent.NodeRemoved, node)
+ QCoreApplication.sendEvent(widget, ev)
item.widget = None
self.widget_for_node_removed.emit(node, widget)
self.delete_widget_for_node(node, widget)
@@ -398,7 +412,7 @@ def __process_init_queue(self):
if self.__init_queue:
node = self.__init_queue.popleft()
assert self.__workflow is not None
- assert node in self.__workflow.nodes
+ assert node in self.__workflow.all_nodes()
log.debug("__process_init_queue: '%s'", node.title)
try:
self.ensure_created(node)
@@ -443,7 +457,7 @@ def raise_widgets_to_front(self):
if item is not None and item.widget is not None
else False)
,
- map(self.__item_for_node.get, workflow.nodes))
+ map(self.__item_for_node.get, workflow.all_nodes()))
self.__raise_and_activate(items)
def set_float_widgets_on_top(self, float_on_top):
@@ -465,7 +479,7 @@ def save_window_state(self):
workflow = self.__workflow # type: Scheme
state = []
- for node in workflow.nodes: # type: SchemeNode
+ for node in workflow.all_nodes(): # type: SchemeNode
item = self.__item_for_node.get(node, None)
if item is None:
continue
@@ -486,13 +500,13 @@ def restore_window_state(self, state):
workflow = self.__workflow # type: Scheme
visible = {node for node, _ in state}
# first hide all other widgets
- for node in workflow.nodes:
+ for node in workflow.all_nodes():
if node not in visible:
# avoid creating widgets if not needed
item = self.__item_for_node.get(node, None)
if item is not None and item.widget is not None:
item.widget.hide()
- allnodes = set(workflow.nodes)
+ allnodes = set(workflow.all_nodes())
# restore state for visible group; windows are stacked as they appear
# in the state list.
w = None
@@ -537,7 +551,7 @@ def __on_raise_ancestors(self, node):
scheme = self.scheme()
assert scheme is not None
ancestors = [self.__item_for_node.get(p)
- for p in scheme.parents(item.node)]
+ for p in scheme.parent_nodes(item.node)]
self.__raise_and_activate(filter(None, reversed(ancestors)))
@Slot(SchemeNode)
@@ -551,7 +565,7 @@ def __on_raise_descendants(self, node):
scheme = self.scheme()
assert scheme is not None
descendants = [self.__item_for_node.get(p)
- for p in scheme.children(item.node)]
+ for p in scheme.child_nodes(item.node)]
self.__raise_and_activate(filter(None, reversed(descendants)))
def __raise_and_activate(self, items):
@@ -586,8 +600,8 @@ def __activate_widget_for_node(self, node): # type: (SchemeNode) -> None
item.errorwidget.raise_()
item.errorwidget.activateWindow()
- def __on_activate_parent(self):
- event = WorkflowEvent(WorkflowEvent.ActivateParentRequest)
+ def __on_activate_parent(self, node):
+ event = NodeEvent(WorkflowEvent.ActivateParentRequest, node)
QCoreApplication.sendEvent(self.scheme(), event)
def __on_link_added_removed(self, link: Link):
diff --git a/orangecanvas/utils/__init__.py b/orangecanvas/utils/__init__.py
index 95e2572d4..b2d333ad4 100644
--- a/orangecanvas/utils/__init__.py
+++ b/orangecanvas/utils/__init__.py
@@ -1,12 +1,15 @@
import enum
+import itertools
import operator
import types
+import unicodedata
+from collections import deque, Counter
from functools import reduce
import typing
from typing import (
Iterable, Set, Any, Optional, Union, Tuple, Callable, Mapping, List, Dict,
- SupportsInt, cast, overload
+ SupportsInt, Container, cast, overload
)
from AnyQt.QtCore import Qt
@@ -31,12 +34,17 @@
"group_by_all",
"mapping_get",
"findf",
+ "index_where",
"set_flag",
"is_flag_set",
"qsizepolicy_is_expanding",
"qsizepolicy_is_shrinking",
"is_event_source_mouse",
"UNUSED",
+ "apply_all",
+ "uniquify",
+ "enumerate_strings",
+ "is_printable",
]
if typing.TYPE_CHECKING:
@@ -290,6 +298,19 @@ def findf(iterable, predicate, default=None):
return typing.cast('Union[A, B]', default)
+def index_where(iterable, predicate):
+ # type: (Iterable[A], Callable[[A], bool]) -> Optional[int]
+ """
+ Return the first index of el in `iterable` where `predicate(el)` returns True.
+
+ If no element matches return `None`.
+ """
+ for i, el in enumerate(iterable):
+ if predicate(el):
+ return i
+ return None
+
+
def set_flag(flags, mask, on=True):
# type: (F, Union[SupportsInt, enum.Flag], bool) -> F
if not isinstance(mask, enum.Flag):
@@ -354,3 +375,57 @@ def UNUSED(*_unused_args) -> None:
... UNUSED(bar, baz)
... return True
"""
+
+
+def apply_all(op, seq):
+ # type: (Callable[[A], Any], Iterable[A]) -> None
+ """Apply `op` on all elements of `seq`."""
+ # from itertools recipes `consume`
+ deque(map(op, seq), maxlen=0)
+
+
+def uniquify(item, names, pattern="{item}-{_}", start=0):
+ # type: (str, Container[str], str, int) -> str
+ """
+ Append a number to `item` such that it will be unique among `names`.
+
+ >>> uniquify("A", [])
+ 'A-0'
+ >>> uniquify("A", ["A-0"])
+ 'A-1'
+ >>> uniquify("A", ["A-0", "A-1"])
+ 'A-2'
+ """
+ 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 enumerate_strings(items, pattern="{item}-{_}"):
+ """
+ Return with possibly appended numbers such that all are unique.
+ """
+ items = list(items)
+ counts = Counter(items)
+ if len(counts) == len(items):
+ return items
+ for i in range(len(items)):
+ items_ = items[:i] + items[i + 1:]
+ item = items[i]
+ if counts[item] > 1:
+ items[i] = uniquify(item, items_, pattern=pattern, start=1)
+ counts[items[i]] += 1
+ return items
+
+
+# All control character categories.
+_control = {"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
diff --git a/orangecanvas/utils/pickle.py b/orangecanvas/utils/pickle.py
index 68f0a02d4..01ae18e63 100644
--- a/orangecanvas/utils/pickle.py
+++ b/orangecanvas/utils/pickle.py
@@ -5,7 +5,7 @@
from AnyQt.QtCore import QSettings
from orangecanvas import config
-from ..scheme import Scheme, SchemeNode, SchemeLink, BaseSchemeAnnotation
+from ..scheme import Scheme, Node, Link, Annotation, MetaNode
class Pickler(pickle.Pickler):
@@ -16,12 +16,14 @@ def __init__(self, file, document):
def persistent_id(self, obj):
if isinstance(obj, Scheme):
return 'scheme'
- elif isinstance(obj, SchemeNode) and obj in self.document.cleanNodes():
- return "SchemeNode_" + str(self.document.cleanNodes().index(obj))
- elif isinstance(obj, SchemeLink) and obj in self.document.cleanLinks():
- return "SchemeLink_" + str(self.document.cleanLinks().index(obj))
- elif isinstance(obj, BaseSchemeAnnotation) and obj in self.document.cleanAnnotations():
- return "BaseSchemeAnnotation_" + str(self.document.cleanAnnotations().index(obj))
+ elif isinstance(obj, MetaNode) and obj is self.document.scheme().root():
+ return 'root'
+ elif isinstance(obj, Node) and obj in self.document.cleanNodes():
+ return "Node_" + str(self.document.cleanNodes().index(obj))
+ elif isinstance(obj, Link) and obj in self.document.cleanLinks():
+ return "Link_" + str(self.document.cleanLinks().index(obj))
+ elif isinstance(obj, Annotation) and obj in self.document.cleanAnnotations():
+ return "Annotation_" + str(self.document.cleanAnnotations().index(obj))
else:
return None
@@ -34,15 +36,17 @@ def __init__(self, file, scheme):
def persistent_load(self, pid: str):
if pid == 'scheme':
return self.scheme
- elif pid.startswith('SchemeNode_'):
+ elif pid == "root":
+ return self.scheme.root()
+ elif pid.startswith('Node_'):
node_index = int(pid.split('_')[1])
- return self.scheme.nodes[node_index]
- elif pid.startswith('SchemeLink_'):
+ return self.scheme.all_nodes()[node_index]
+ elif pid.startswith('Link_'):
link_index = int(pid.split('_')[1])
- return self.scheme.links[link_index]
- elif pid.startswith('BaseSchemeAnnotation_'):
+ return self.scheme.all_links()[link_index]
+ elif pid.startswith('Annotation_'):
annotation_index = int(pid.split('_')[1])
- return self.scheme.annotations[annotation_index]
+ return self.scheme.all_annotations()[annotation_index]
else:
raise pickle.UnpicklingError("Unsupported persistent object")
diff --git a/orangecanvas/utils/qinvoke.py b/orangecanvas/utils/qinvoke.py
index 9f602c38f..0e67ada75 100644
--- a/orangecanvas/utils/qinvoke.py
+++ b/orangecanvas/utils/qinvoke.py
@@ -164,5 +164,6 @@ def connect_with_context(
Like the QObject.connect overload that takes a explicit context QObject,
which is not exposed by PyQt
"""
- f = qinvoke(functor, context=context, type=type)
- return signal.connect(f)
+ func = qinvoke(functor, context=context, type=type)
+ signal.connect(func)
+ return func
diff --git a/orangecanvas/utils/tests/test_utils.py b/orangecanvas/utils/tests/test_utils.py
index f7a27ef2f..060a7ebe0 100644
--- a/orangecanvas/utils/tests/test_utils.py
+++ b/orangecanvas/utils/tests/test_utils.py
@@ -1,6 +1,6 @@
import unittest
-from .. import assocf, assocv
+from .. import assocf, assocv, uniquify, enumerate_strings
class TestUtils(unittest.TestCase):
@@ -16,3 +16,17 @@ def test_assoc(self):
self.assertEqual(res, expected)
res = assocv(seq, key)
self.assertEqual(res, expected)
+
+ def test_uniquify(self):
+ self.assertEqual(uniquify("A", []), "A-0")
+ self.assertEqual(uniquify("A", ["A-0"]), "A-1")
+ self.assertEqual(uniquify("A", ["A", "B"]), "A-0")
+ self.assertEqual(uniquify("A", ["A", "A-0", "A-1", "B"]), "A-2")
+
+ def test_enumerate_strings(self):
+ self.assertEqual(enumerate_strings([]), [])
+ self.assertEqual(enumerate_strings(["a", "b"],), ["a", "b"])
+ self.assertEqual(
+ enumerate_strings(["a", "b", "aa", "aa", "aa-2"],),
+ ["a", "b", "aa-1", "aa-3", "aa-2"]
+ )
diff --git a/setup.cfg b/setup.cfg
index 88b3d5737..cc1fc56a4 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,58 @@
[metadata]
-license_file=LICENSE.txt
+
+name = orange-canvas-core
+version = 0.3.0a1.dev0
+description = Core component of Orange Canvas
+long_description = file: README.rst
+keywords =
+home_page = http://orange.biolab.si/
+author = Bioinformatics Laboratory, FRI UL
+
+project_urls =
+ Home Page = https://github.com/biolab/orange-canvas-core
+ Source = https://github.com/biolab/orange-canvas-core
+ Issue Tracker = https://github.com/biolab/orange-canvas-core/issues
+ Documentation = https://orange-canvas-core.readthedocs.io/en/latest/
+
+license = GPLv3
+license_file = LICENSE.txt
+
+classifiers =
+ Development Status :: 1 - Planning
+ Environment :: X11 Applications :: Qt
+ Programming Language :: Python :: 3
+ License :: OSI Approved :: GNU General Public License v3 (GPLv3)
+ Operating System :: OS Independent
+ Topic :: Scientific/Engineering :: Visualization
+ Topic :: Software Development :: Libraries :: Python Modules
+ Intended Audience :: Education
+ Intended Audience :: Developers
+
+[options]
+
+packages = find:
+
+install_requires =
+ AnyQt >= 0.2.0
+ docutils
+ commonmark >= 0.8.1
+ requests
+ requests-cache
+ pip >= 23.3
+ dictdiffer
+ qasync >= 0.13.0
+ typing_extensions
+ packaging
+ numpy
+
+setup_requires=
+ setuptools >=30.0
+
+python_requires = >=3.10
+
+[options.extras_require]
+DOCBUILD = sphinx; sphinx-rtd-theme;
+
[coverage:run]
source =
diff --git a/setup.py b/setup.py
old mode 100755
new mode 100644
index aac91f453..ba5043f4f
--- a/setup.py
+++ b/setup.py
@@ -1,70 +1,10 @@
#! /usr/bin/env python
import os
-from setuptools import setup, find_packages
+from setuptools import setup
from setuptools.command.install import install
-NAME = "orange-canvas-core"
-VERSION = "0.2.6.dev0"
-DESCRIPTION = "Core component of Orange Canvas"
-
-with open("README.rst", "rt", encoding="utf-8") as f:
- LONG_DESCRIPTION = f.read()
-
-URL = "http://orange.biolab.si/"
-AUTHOR = "Bioinformatics Laboratory, FRI UL"
-AUTHOR_EMAIL = 'contact@orange.biolab.si'
-
-LICENSE = "GPLv3"
-DOWNLOAD_URL = 'https://github.com/biolab/orange-canvas-core'
-PACKAGES = find_packages()
-
-PACKAGE_DATA = {
- "orangecanvas": ["icons/*.svg", "icons/*png"],
- "orangecanvas.styles": ["*.qss", "orange/*.svg"],
-}
-
-INSTALL_REQUIRES = (
- "AnyQt>=0.2.0",
- "docutils",
- "commonmark>=0.8.1",
- "requests",
- "requests-cache",
- "pip>=18.0",
- "dictdiffer",
- "qasync>=0.10.0",
- "typing_extensions",
- "packaging",
- "numpy",
-)
-
-
-CLASSIFIERS = (
- "Development Status :: 1 - Planning",
- "Environment :: X11 Applications :: Qt",
- "Programming Language :: Python :: 3",
- "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
- "Operating System :: OS Independent",
- "Topic :: Scientific/Engineering :: Visualization",
- "Topic :: Software Development :: Libraries :: Python Modules",
- "Intended Audience :: Education",
- "Intended Audience :: Developers",
-)
-
-EXTRAS_REQUIRE = {
- 'DOCBUILD': ['sphinx', 'sphinx-rtd-theme'],
-}
-
-PROJECT_URLS = {
- "Bug Reports": "https://github.com/biolab/orange-canvas-core/issues",
- "Source": "https://github.com/biolab/orange-canvas-core/",
- "Documentation": "https://orange-canvas-core.readthedocs.io/en/latest/",
-}
-
-PYTHON_REQUIRES = ">=3.10"
-
-
class InstallMultilingualCommand(install):
def run(self):
super().run()
@@ -81,23 +21,9 @@ def compile_to_multilingual(self):
if __name__ == "__main__":
+ # setup.cfg has authoritative package descriptions
setup(
- name=NAME,
- version=VERSION,
- description=DESCRIPTION,
- long_description=LONG_DESCRIPTION,
- long_description_content_type="text/x-rst",
- url=URL,
- author=AUTHOR,
- author_email=AUTHOR_EMAIL,
- license=LICENSE,
- packages=PACKAGES,
- package_data=PACKAGE_DATA,
- install_requires=INSTALL_REQUIRES,
cmdclass={
'install': InstallMultilingualCommand,
},
- extras_require=EXTRAS_REQUIRE,
- project_urls=PROJECT_URLS,
- python_requires=PYTHON_REQUIRES,
)