diff --git a/docs/source/orangecanvas/canvas.rst b/docs/source/orangecanvas/canvas.rst index 114633f9d..2785ece2e 100644 --- a/docs/source/orangecanvas/canvas.rst +++ b/docs/source/orangecanvas/canvas.rst @@ -12,3 +12,4 @@ Canvas (``canvas``) canvas.items.nodeitem canvas.items.linkitem canvas.items.annotationitem + canvas.utils diff --git a/docs/source/orangecanvas/canvas.scene.rst b/docs/source/orangecanvas/canvas.scene.rst index 4fdd36e53..6228978dc 100644 --- a/docs/source/orangecanvas/canvas.scene.rst +++ b/docs/source/orangecanvas/canvas.scene.rst @@ -45,6 +45,3 @@ Canvas Scene (``scene``) .. autoattribute:: node_item_hovered(NodeItem) .. autoattribute:: link_item_hovered(LinkItem) - - -.. autofunction:: grab_svg diff --git a/docs/source/orangecanvas/canvas.utils.rst b/docs/source/orangecanvas/canvas.utils.rst new file mode 100644 index 000000000..1df8d20a4 --- /dev/null +++ b/docs/source/orangecanvas/canvas.utils.rst @@ -0,0 +1,9 @@ +.. canvas-utils: + +======================== +Canvas Utils (``utils``) +======================== + +.. automodule:: orangecanvas.canvas.utils + +.. autofunction:: grab_svg diff --git a/docs/source/orangecanvas/scheme.annotation.rst b/docs/source/orangecanvas/scheme.annotation.rst index a119596ed..fcbd6e99c 100644 --- a/docs/source/orangecanvas/scheme.annotation.rst +++ b/docs/source/orangecanvas/scheme.annotation.rst @@ -7,7 +7,7 @@ Scheme Annotations (``annotations``) .. automodule:: orangecanvas.scheme.annotations -.. autoclass:: BaseSchemeAnnotation +.. autoclass:: Annotation :members: :member-order: bysource :show-inheritance: @@ -17,13 +17,13 @@ Scheme Annotations (``annotations``) Signal emitted when the geometry of the annotation changes -.. autoclass:: SchemeArrowAnnotation +.. autoclass:: ArrowAnnotation :members: :member-order: bysource :show-inheritance: -.. autoclass:: SchemeTextAnnotation +.. autoclass:: TextAnnotation :members: :member-order: bysource :show-inheritance: diff --git a/docs/source/orangecanvas/scheme.link.rst b/docs/source/orangecanvas/scheme.link.rst index 9c12441ea..686a7cabd 100644 --- a/docs/source/orangecanvas/scheme.link.rst +++ b/docs/source/orangecanvas/scheme.link.rst @@ -1,13 +1,13 @@ .. schemelink: -====================== -Scheme Link (``link``) -====================== +=============== +Link (``link``) +=============== .. automodule:: orangecanvas.scheme.link -.. autoclass:: SchemeLink +.. autoclass:: Link :members: :exclude-members: enabled_changed, @@ -18,3 +18,6 @@ Scheme Link (``link``) .. autoattribute:: enabled_changed(enabled) .. autoattribute:: dynamic_enabled_changed(enabled) + + +.. autoclass:: SchemeLink diff --git a/docs/source/orangecanvas/scheme.metanode.rst b/docs/source/orangecanvas/scheme.metanode.rst new file mode 100644 index 000000000..fcee66b0b --- /dev/null +++ b/docs/source/orangecanvas/scheme.metanode.rst @@ -0,0 +1,38 @@ +.. scheme-meta-node: + +======================== +Meta Node (``metanode``) +======================== + +.. automodule:: orangecanvas.scheme.metanode + + +.. autoclass:: MetaNode + :members: + :exclude-members: + node_inserted, + node_removed, + link_inserted, + link_removed, + annotation_inserted, + annotation_removed + :member-order: bysource + :show-inheritance: + + .. autoattribute:: node_inserted(index, node) + + .. autoattribute:: node_removed(node) + + .. autoattribute:: link_inserted(int, node) + + .. autoattribute:: link_removed(node) + + .. autoattribute:: annotation_inserted(index, node) + + .. autoattribute:: annotation_removed(node) + + +.. autoclass:: InputNode + +.. autoclass:: OutputNode + diff --git a/docs/source/orangecanvas/scheme.node.rst b/docs/source/orangecanvas/scheme.node.rst index c0fe4ed5f..2addcb166 100644 --- a/docs/source/orangecanvas/scheme.node.rst +++ b/docs/source/orangecanvas/scheme.node.rst @@ -1,19 +1,22 @@ .. scheme-node: -====================== -Scheme Node (``node``) -====================== +=============== +Node (``node``) +=============== .. automodule:: orangecanvas.scheme.node - -.. autoclass:: SchemeNode +.. autoclass:: Node :members: :exclude-members: title_changed, position_changed, progress_changed, - processing_state_changed + processing_state_changed, + input_channel_inserted, + input_channel_removed, + output_channel_inserted, + output_channel_removed :member-order: bysource :show-inheritance: @@ -24,3 +27,17 @@ Scheme Node (``node``) .. autoattribute:: progress_changed(progress) .. autoattribute:: processing_state_changed(state) + + .. autoattribute:: input_channel_inserted(index, signal) + + .. autoattribute:: input_channel_removed(signal) + + .. autoattribute:: output_channel_inserted(index, signal) + + .. autoattribute:: output_channel_removed(signal) + + +.. autoclass:: SchemeNode + :members: + :member-order: bysource + :show-inheritance: diff --git a/docs/source/orangecanvas/scheme.rst b/docs/source/orangecanvas/scheme.rst index 90847d55f..a0b29623e 100644 --- a/docs/source/orangecanvas/scheme.rst +++ b/docs/source/orangecanvas/scheme.rst @@ -12,6 +12,7 @@ Scheme (``scheme``) scheme.scheme scheme.node + scheme.metanode scheme.link scheme.annotation scheme.readwrite diff --git a/docs/source/orangecanvas/scheme.scheme.rst b/docs/source/orangecanvas/scheme.scheme.rst index 609cd3c44..6998d9cc3 100644 --- a/docs/source/orangecanvas/scheme.scheme.rst +++ b/docs/source/orangecanvas/scheme.scheme.rst @@ -8,7 +8,10 @@ Scheme (``scheme``) .. autoclass:: Scheme :members: - :exclude-members: runtime_env_changed + :exclude-members: + runtime_env_changed, + children, + parents :member-order: bysource :show-inheritance: diff --git a/orangecanvas/application/canvasmain.py b/orangecanvas/application/canvasmain.py index 6f781b18f..710a7dadd 100644 --- a/orangecanvas/application/canvasmain.py +++ b/orangecanvas/application/canvasmain.py @@ -5,7 +5,6 @@ import os import sys import logging -import operator import io import traceback from concurrent import futures @@ -29,7 +28,7 @@ QWhatsThisClickedEvent, QShowEvent, QCloseEvent ) from AnyQt.QtCore import ( - Qt, QObject, QEvent, QSize, QUrl, QByteArray, QFileInfo, + Signal, Qt, QObject, QEvent, QSize, QUrl, QByteArray, QFileInfo, QSettings, QStandardPaths, QAbstractItemModel, QMimeData, QT_VERSION) try: @@ -42,11 +41,6 @@ except ImportError: QWebView = None # type: ignore - -from AnyQt.QtCore import ( - pyqtProperty as Property, pyqtSignal as Signal -) - from ..scheme import Scheme, IncompatibleChannelTypeError, SchemeNode from ..scheme import readwrite from ..scheme.readwrite import UnknownWidgetDefinition @@ -78,7 +72,7 @@ from ..utils.qinvoke import qinvoke from ..utils.pickle import Pickler, Unpickler, glob_scratch_swps, swp_name, \ canvas_scratch_name_memo, register_loaded_swp -from ..utils import unique, group_by_all, set_flag, findf +from ..utils import unique, group_by_all, set_flag, findf, index_where from ..utils.asyncutils import get_event_loop from ..utils.qobjref import qobjref from . import welcomedialog @@ -87,7 +81,7 @@ from .. import config from . import examples from ..resources import load_styled_svg_icon -from ..canvas import scene +from ..canvas.utils import grab_svg log = logging.getLogger(__name__) @@ -99,25 +93,6 @@ def user_documents_path(): return QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation) -class FakeToolBar(QToolBar): - """A Toolbar with no contents (used to reserve top and bottom margins - on the main window). - - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setFloatable(False) - self.setMovable(False) - - # Don't show the tool bar action in the main window's - # context menu. - self.toggleViewAction().setVisible(False) - - def paintEvent(self, event): - # Do nothing. - pass - - class DockWidget(QDockWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -135,8 +110,6 @@ class CanvasMainWindow(QMainWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - self.__scheme_margins_enabled = True self.__document_title = "untitled" self.__first_show = True self.__is_transient = True @@ -181,28 +154,10 @@ def __init__(self, *args, **kwargs): def setup_ui(self): """Setup main canvas ui """ - # Two dummy tool bars to reserve space - self.__dummy_top_toolbar = FakeToolBar( - objectName="__dummy_top_toolbar") - self.__dummy_bottom_toolbar = FakeToolBar( - objectName="__dummy_bottom_toolbar") - - self.__dummy_top_toolbar.setFixedHeight(20) - self.__dummy_bottom_toolbar.setFixedHeight(20) - - self.addToolBar(Qt.TopToolBarArea, self.__dummy_top_toolbar) - self.addToolBar(Qt.BottomToolBarArea, self.__dummy_bottom_toolbar) - self.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) self.setCorner(Qt.BottomRightCorner, Qt.RightDockWidgetArea) self.setDockOptions(QMainWindow.AnimatedDocks) - # Create an empty initial scheme inside a container with fixed - # margins. - w = QWidget() - w.setLayout(QVBoxLayout()) - w.layout().setContentsMargins(20, 0, 10, 0) - self.scheme_widget = SchemeEditWidget() self.scheme_widget.setDropHandlers([interactions.PluginDropHandler(),]) self.set_scheme(config.workflow_constructor(parent=self)) @@ -215,9 +170,7 @@ def setup_ui(self): self.scheme_widget.setAcceptDrops(True) self.scheme_widget.view().viewport().installEventFilter(dropfilter) - w.layout().addWidget(self.scheme_widget) - - self.setCentralWidget(w) + self.setCentralWidget(self.scheme_widget) # Drop shadow around the scheme document frame = DropShadowFrame(radius=15) @@ -342,9 +295,6 @@ def setup_ui(self): self.dock_widget.expandedChanged.connect(self._on_tool_dock_expanded) self.addDockWidget(Qt.LeftDockWidgetArea, self.dock_widget) - self.dock_widget.dockLocationChanged.connect( - self._on_dock_location_changed - ) self.output_dock = DockWidget( self.tr("Log"), self, objectName="output-dock", @@ -600,15 +550,6 @@ def config_url_action(action, role): # TODO: This is bad (should be moved here). self.dock_help_action = None - self.toogle_margins_action = QAction( - self.tr("Show Workflow Margins"), self, - checkable=True, - toolTip=self.tr("Show margins around the workflow view."), - ) - self.toogle_margins_action.setChecked(True) - self.toogle_margins_action.toggled.connect( - self.set_scheme_margins_enabled) - self.float_widgets_on_top_action = QAction( self.tr("Display Widgets on Top"), self, checkable=True, @@ -721,7 +662,6 @@ def setup_menu(self): sep = self.view_menu.addSeparator() sep.setObjectName("view-zoom-actions-separator") - self.view_menu.addAction(self.toogle_margins_action) menu_bar.addMenu(self.view_menu) # Options menu @@ -788,9 +728,6 @@ def restore(self): settings.value("toolbox-dock-exclusive", False, type=bool) ) - self.toogle_margins_action.setChecked( - settings.value("scheme-margins-enabled", False, type=bool) - ) self.show_output_action.setChecked( settings.value("output-dock/is-visible", False, type=bool)) @@ -940,9 +877,11 @@ def on_quick_category_action(self, action): popup.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) model = self.__registry_model assert model is not None - i = index(self.widget_registry.categories(), category, - predicate=lambda name, cat: cat.name == name) - if i != -1: + i = index_where( + self.widget_registry.categories(), + lambda cat: cat.name == category + ) + if i is not None: popup.setModel(model) popup.setRootIndex(model.index(i, 0)) popup.adjustSize() @@ -960,39 +899,6 @@ def on_quick_category_action(self, action): self.dock_widget.expand() - def set_scheme_margins_enabled(self, enabled): - # type: (bool) -> None - """Enable/disable the margins around the scheme document. - """ - if self.__scheme_margins_enabled != enabled: - self.__scheme_margins_enabled = enabled - self.__update_scheme_margins() - - def _scheme_margins_enabled(self): - # type: () -> bool - return self.__scheme_margins_enabled - - scheme_margins_enabled: bool - scheme_margins_enabled = Property( # type: ignore - bool, _scheme_margins_enabled, set_scheme_margins_enabled) - - def __update_scheme_margins(self): - """Update the margins around the scheme document. - """ - enabled = self.__scheme_margins_enabled - self.__dummy_top_toolbar.setVisible(enabled) - self.__dummy_bottom_toolbar.setVisible(enabled) - central = self.centralWidget() - - margin = 20 if enabled else 0 - - if self.dockWidgetArea(self.dock_widget) == Qt.LeftDockWidgetArea: - margins = (margin // 2, 0, margin, 0) - else: - margins = (margin, 0, margin // 2, 0) - - central.layout().setContentsMargins(*margins) - def is_transient(self): # type: () -> bool """ @@ -1858,7 +1764,7 @@ def save(): def __save_as_svg(self, path): doc = self.current_document() - content = scene.grab_svg(doc.scene()) + content = grab_svg(doc.currentScene()) with self._handle_os_write_error(): with open(path, "wt", encoding="utf-8") as f: f.write(content) @@ -2248,7 +2154,7 @@ def add_recent_scheme(self, title, path): # Find the separator action in the menu (after 'Browse Recent') recent_actions = self.recent_menu.actions() - begin_index = index(recent_actions, self.recent_menu_begin) + begin_index = recent_actions.index(self.recent_menu_begin) action_before = recent_actions[begin_index + 1] self.recent_menu.insertAction(action_before, action) @@ -2291,13 +2197,6 @@ def _on_recent_scheme_action(self, action): filename = str(action.data()) self.open_scheme_file(filename) - def _on_dock_location_changed(self, location): - # type: (Qt.DockWidgetArea) -> None - """Location of the dock_widget has changed, fix the margins - if necessary. - """ - self.__update_scheme_margins() - def set_tool_dock_expanded(self, expanded): # type: (bool) -> None """ @@ -2357,9 +2256,6 @@ def closeEvent(self, event): settings.setValue("state", state) settings.setValue("canvasdock/expanded", self.dock_widget.expanded()) - settings.setValue("scheme-margins-enabled", - self.scheme_margins_enabled) - settings.setValue("widgettoolbox/state", self.widgets_tool_box.saveState()) @@ -2630,27 +2526,6 @@ def updated_flags(flags, mask, state): return set_flag(flags, mask, state) - -def identity(item): - return item - - -def index(sequence, *what, **kwargs): - """index(sequence, what, [key=None, [predicate=None]]) - - Return index of `what` in `sequence`. - - """ - what = what[0] - key = kwargs.get("key", identity) - predicate = kwargs.get("predicate", operator.eq) - for i, item in enumerate(sequence): - item_key = key(item) - if predicate(what, item_key): - return i - raise ValueError("%r not in sequence" % what) - - def category_filter_function(state): # type: (Dict[str, bool]) -> Callable[[Any], bool] def category_filter(desc): @@ -2708,7 +2583,11 @@ def scheme_requires( desc = readwrite.parse_ows_stream(stream) if registry is not None: desc = readwrite.resolve_replaced(desc, registry) - return list(unique(m.project_name for m in desc.nodes if m.project_name)) + nodes = filter( + lambda n: isinstance(n, readwrite._node), + desc.iter_all_nodes() + ) + return list(unique(m.project_name for m in nodes if m.project_name)) K = TypeVar("K") diff --git a/orangecanvas/application/tests/test_mainwindow.py b/orangecanvas/application/tests/test_mainwindow.py index 927257c42..47227ccbd 100644 --- a/orangecanvas/application/tests/test_mainwindow.py +++ b/orangecanvas/application/tests/test_mainwindow.py @@ -60,7 +60,6 @@ def test_create_new_window(self): w.show() new.show() - w.set_scheme_margins_enabled(True) new.deleteLater() stream = TextStream() w.connect_output_stream(stream) @@ -107,9 +106,9 @@ def test_create_toolbox(self): grid = toolbox.widget(0) button = grid.findChild(QToolButton) # type: QToolButton - self.assertEqual(len(wf.nodes), 0) + self.assertEqual(len(wf.root().nodes()), 0) button.click() - self.assertEqual(len(wf.nodes), 1) + self.assertEqual(len(wf.root().nodes()), 1) def test_create_category_toolbar(self): w = self.w @@ -270,8 +269,8 @@ def test(predicate): node.properties['dummy'] = 0 test(lambda: - self.assertEqual(w2.scheme_widget.scheme().nodes[0].properties['dummy'], 0)) - w2_node = w2.scheme_widget.scheme().nodes[0] + self.assertEqual(w2.scheme_widget.scheme().root().nodes()[0].properties['dummy'], 0)) + w2_node = w2.scheme_widget.scheme().root().nodes()[0] # test widget change properties node.properties['dummy'] = 1 @@ -284,29 +283,29 @@ def test(predicate): # test link add w.current_document().addLink(link) test(lambda: - self.assertTrue(w2.scheme_widget.scheme().links)) + self.assertTrue(w2.scheme_widget.scheme().root().links())) # test link remove w.current_document().removeLink(link) test(lambda: - self.assertFalse(w2.scheme_widget.scheme().links)) + self.assertFalse(w2.scheme_widget.scheme().root().links())) # test widget remove w.scheme_widget.removeNode(node) w.scheme_widget.removeNode(node2) test(lambda: - self.assertFalse(w2.scheme_widget.scheme().nodes)) + self.assertFalse(w2.scheme_widget.scheme().root().nodes())) # test annotation add a = SchemeTextAnnotation((200, 300, 50, 20), "text") w.current_document().addAnnotation(a) test(lambda: - self.assertTrue(w2.scheme_widget.scheme().annotations)) + self.assertTrue(w2.scheme_widget.scheme().root().annotations())) # test annotation remove w.current_document().removeAnnotation(a) test(lambda: - self.assertFalse(w2.scheme_widget.scheme().annotations)) + self.assertFalse(w2.scheme_widget.scheme().root().annotations())) def test_open_ows_req(self): w = self.w diff --git a/orangecanvas/canvas/items/__init__.py b/orangecanvas/canvas/items/__init__.py index ef5a980d4..41cc73f4f 100644 --- a/orangecanvas/canvas/items/__init__.py +++ b/orangecanvas/canvas/items/__init__.py @@ -6,4 +6,4 @@ from .nodeitem import NodeItem, NodeAnchorItem, NodeBodyItem, SHADOW_COLOR from .nodeitem import SourceAnchorItem, SinkAnchorItem, AnchorPoint from .linkitem import LinkItem, LinkCurveItem -from .annotationitem import TextAnnotation, ArrowAnnotation +from .annotationitem import TextAnnotation, ArrowAnnotation, AnnotationItem diff --git a/orangecanvas/canvas/items/annotationitem.py b/orangecanvas/canvas/items/annotationitem.py index da7c26b1d..0c23eb04a 100644 --- a/orangecanvas/canvas/items/annotationitem.py +++ b/orangecanvas/canvas/items/annotationitem.py @@ -23,12 +23,16 @@ from .graphicstextitem import GraphicsTextEdit -class Annotation(QGraphicsWidget): +class AnnotationItem(QGraphicsWidget): """ Base class for annotations in the canvas scheme. """ +# alias for back-compatibility +Annotation = AnnotationItem + + class GraphicsTextEdit(GraphicsTextEdit): """ QGraphicsTextItem subclass defining an additional placeholderText diff --git a/orangecanvas/canvas/items/linkitem.py b/orangecanvas/canvas/items/linkitem.py index 03cbef27c..a8ddbe829 100644 --- a/orangecanvas/canvas/items/linkitem.py +++ b/orangecanvas/canvas/items/linkitem.py @@ -24,8 +24,7 @@ from .graphicstextitem import GraphicsTextItem from .utils import stroke_path, qpainterpath_sub_path from ...registry import InputSignal, OutputSignal - -from ...scheme import SchemeLink +from ...scheme import Link if typing.TYPE_CHECKING: from . import NodeItem, AnchorPoint @@ -209,7 +208,7 @@ def QPainterPath_addLine(path, line): return p1 -_State = SchemeLink.State +_State = Link.State class LinkItem(QGraphicsWidget): @@ -235,19 +234,19 @@ class LinkItem(QGraphicsWidget): Z_VALUE = 0 #: Runtime link state value - #: These are pulled from SchemeLink.State for ease of binding to it's + #: These are pulled from Link.State for ease of binding to its #: state - State = SchemeLink.State + State = Link.State #: The link has no associated state. - NoState = SchemeLink.NoState + NoState = Link.NoState #: Link is empty; the source node does not have any value on output - Empty = SchemeLink.Empty + Empty = Link.Empty #: Link is active; the source node has a valid value on output - Active = SchemeLink.Active + Active = Link.Active #: The link is pending; the sink node is scheduled for update - Pending = SchemeLink.Pending + Pending = Link.Pending #: The link's input is marked as invalidated (not yet available). - Invalidated = SchemeLink.Invalidated + Invalidated = Link.Invalidated def __init__(self, parent=None, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None diff --git a/orangecanvas/canvas/items/nodeitem.py b/orangecanvas/canvas/items/nodeitem.py index 70e0f852a..ee1559f9b 100644 --- a/orangecanvas/canvas/items/nodeitem.py +++ b/orangecanvas/canvas/items/nodeitem.py @@ -6,6 +6,7 @@ """ import typing import string +import warnings from operator import attrgetter from itertools import groupby @@ -13,7 +14,7 @@ from functools import reduce from xml.sax.saxutils import escape -from typing import Dict, Any, Optional, List, Iterable, Tuple, Union +from typing import Dict, Any, Optional, List, Iterable, Tuple, Sequence, Union from AnyQt.QtWidgets import ( QGraphicsItem, QGraphicsObject, QGraphicsWidget, @@ -41,6 +42,7 @@ from ...gui.iconengine import StyledIconEngine from ...gui.utils import disconnected +from ...scheme import Node from ...scheme.node import UserMessage from ...registry import NAMED_COLORS, WidgetDescription, CategoryDescription, \ InputSignal, OutputSignal @@ -626,6 +628,9 @@ def setSignals(self, signals): self.animGroup.addAnimation(lblAnim) self.__signalLabelAnims.append(lblAnim) + def signals(self): + return self.__signals + def setIncompatible(self, enabled): if self.__incompatible != enabled: self.__incompatible = enabled @@ -1221,7 +1226,10 @@ def __init__(self, widget_description=None, parent=None, **kwargs): self.shapeItem = NodeBodyItem(self) self.shapeItem.setShapeRect(shape_rect) self.shapeItem.setAnimationEnabled(self.__animationEnabled) - + self.iconItem = GraphicsIconItem(self.shapeItem, iconSize=QSize(36, 36)) + # assuming light-ish color background + self.iconItem.setPalette(styles.breeze_light()) + self.iconItem.setPos(-18, -18) # Rect for widget's 'ears'. anchor_rect = QRectF(-31, -31, 62, 62) self.inputAnchorItem = SinkAnchorItem(self) @@ -1268,82 +1276,115 @@ def iconItem(standard_pixmap): self.prepareGeometryChange() self.__boundingRect = None - + self.widget_description = None + self.category_description = None if widget_description is not None: + warnings.warn( + "'widget_description' parameter is deprecated", DeprecationWarning, + stacklevel=2 + ) self.setWidgetDescription(widget_description) @classmethod - def from_node(cls, node): + def from_node(cls, node: Node) -> 'NodeItem': """ Create an :class:`NodeItem` instance and initialize it from a - :class:`SchemeNode` instance. - + :class:`Node` instance. """ self = cls() - self.setWidgetDescription(node.description) -# self.setCategoryDescription(node.category) + self.initFrom(node) return self @classmethod - def from_node_meta(cls, meta_description): + def from_node_meta(cls, meta_description: WidgetDescription) -> 'NodeItem': """ Create an `NodeItem` instance from a node meta description. """ self = cls() - self.setWidgetDescription(meta_description) + self.initFrom(meta_description) return self - # TODO: Remove the set[Widget|Category]Description. The user should - # handle setting of icons, title, ... + def initFrom(self, node: typing.Union['Node', WidgetDescription]): + if isinstance(node, Node): + title = node.title + ics, ocs = node.input_channels(), node.output_channels() + icon = node.icon() + color = background_color_from_desc(node) + elif isinstance(node, WidgetDescription): + desc = node + title = desc.name + ics, ocs = desc.inputs, desc.outputs + icon = icon_loader.from_description(desc).get(desc.icon) + color = background_color_from_desc(desc) + else: + raise TypeError + self.setIcon(icon) + self.setTitle(title) + if color.isValid(): + self.setColor(color) + if ics: + self.setInputChannels(ics) + if ocs: + self.setOutputChannels(ocs) + tooltip = NodeItem_toolTipHelper(self) + self.setToolTip(tooltip) + def setWidgetDescription(self, desc): # type: (WidgetDescription) -> None """ Set widget description. """ + warnings.warn( + "'setWidgetDescription' is deprecated", DeprecationWarning, + stacklevel=2 + ) self.widget_description = desc if desc is None: return - - icon = icon_loader.from_description(desc).get(desc.icon) - if icon: - self.setIcon(icon) - - if not self.title(): - self.setTitle(desc.name) - - if desc.inputs: - self.inputAnchorItem.setSignals(desc.inputs) - self.inputAnchorItem.show() - if desc.outputs: - self.outputAnchorItem.setSignals(desc.outputs) - self.outputAnchorItem.show() - - tooltip = NodeItem_toolTipHelper(self) - self.setToolTip(tooltip) + self.initFrom(desc) def setWidgetCategory(self, desc): # type: (CategoryDescription) -> None """ Set the widget category. """ + warnings.warn( + "'setWidgetCategory' is deprecated", DeprecationWarning, + stacklevel=2 + ) self.category_description = desc if desc and desc.background: - background = NAMED_COLORS.get(desc.background, desc.background) - color = QColor(background) + color = background_color_from_desc(desc) if color.isValid(): self.setColor(color) + def setInputChannels(self, signals: Sequence[InputSignal]): + """Set this items input channels.""" + self.inputAnchorItem.setSignals(list(signals)) + self.inputAnchorItem.setVisible(bool(signals)) + + def inputChannels(self) -> Sequence[InputSignal]: + """Return this items input channels.""" + return list(self.inputAnchorItem.signals()) + + def setOutputChannels(self, signals: Sequence[OutputSignal]): + """Set this items output channels.""" + self.outputAnchorItem.setSignals(list(signals)) + self.outputAnchorItem.setVisible(bool(signals)) + + def outputChannels(self) -> Sequence[OutputSignal]: + """Return this items output signals.""" + return list(self.outputAnchorItem.signals()) + def setIcon(self, icon): # type: (QIcon) -> None """ Set the node item's icon (:class:`QIcon`). """ - self.icon_item = GraphicsIconItem( - self.shapeItem, icon=icon, iconSize=QSize(36, 36) - ) - # assuming light-ish color background - self.icon_item.setPalette(styles.breeze_light()) - self.icon_item.setPos(-18, -18) + self.iconItem.setIcon(icon) + + def icon(self) -> QIcon: + return self.iconItem.icon() def setColor(self, color, selectedColor=None): # type: (QColor, Optional[QColor]) -> None @@ -1551,12 +1592,9 @@ def newInputAnchor(self, signal=None): """ Create and return a new input :class:`AnchorPoint`. """ - if not (self.widget_description and self.widget_description.inputs): - raise ValueError("Widget has no inputs.") - anchor = AnchorPoint(self, signal=signal) self.inputAnchorItem.addAnchor(anchor) - + self.inputAnchorItem.setVisible(True) return anchor def removeInputAnchor(self, anchor): @@ -1571,12 +1609,9 @@ def newOutputAnchor(self, signal=None): """ Create and return a new output :class:`AnchorPoint`. """ - if not (self.widget_description and self.widget_description.outputs): - raise ValueError("Widget has no outputs.") - anchor = AnchorPoint(self, signal=signal) self.outputAnchorItem.addAnchor(anchor) - + self.outputAnchorItem.setVisible(True) return anchor def removeOutputAnchor(self, anchor): @@ -1806,23 +1841,21 @@ def NodeItem_toolTipHelper(node, links_in=[], links_out=[]): A list of input links for the node. links_out : list of LinkItem instances A list of output links for the node. - """ - desc = node.widget_description channel_fmt = "
  • {0}
  • " - title_fmt = "{title}
    " title = title_fmt.format(title=escape(node.title())) inputs_list_fmt = "Inputs:
    " outputs_list_fmt = "Outputs:" - if desc.inputs: - inputs = [channel_fmt.format(inp.name) for inp in desc.inputs] + inputs = node.inputChannels() + if inputs: + inputs = [channel_fmt.format(inp.name) for inp in inputs] inputs = inputs_list_fmt.format(inputs="".join(inputs)) else: inputs = "No inputs
    " - - if desc.outputs: - outputs = [channel_fmt.format(out.name) for out in desc.outputs] + outputs = node.outputChannels() + if outputs: + outputs = [channel_fmt.format(out.name) for out in outputs] outputs = outputs_list_fmt.format(outputs="".join(outputs)) else: outputs = "No outputs" @@ -1832,6 +1865,18 @@ def NodeItem_toolTipHelper(node, links_in=[], links_out=[]): return TOOLTIP_TEMPLATE.format(style=style, tooltip=tooltip) +def background_color_from_desc( + desc: Union[WidgetDescription, CategoryDescription, Node] +) -> QColor: + if isinstance(desc, Node) and \ + isinstance(getattr(desc, "description", None), WidgetDescription): + desc = desc.description + background = NAMED_COLORS.get(desc.background, desc.background) + if background is not None: + return QColor(background) + return QColor() + + def parse_format_fields(format_str): # type: (str) -> List[Tuple[str, Tuple[Optional[str], Optional[str]]]] formatter = string.Formatter() diff --git a/orangecanvas/canvas/items/tests/test_linkitem.py b/orangecanvas/canvas/items/tests/test_linkitem.py index c9558d4e0..6c2eafa6b 100644 --- a/orangecanvas/canvas/items/tests/test_linkitem.py +++ b/orangecanvas/canvas/items/tests/test_linkitem.py @@ -16,27 +16,20 @@ def setUp(self): super().setUp() reg = small_testing_registry() - - const_desc = reg.category("Constants") - one_desc = reg.widget("one") self.one_item = one_item = NodeItem() - one_item.setWidgetDescription(one_desc) - one_item.setWidgetCategory(const_desc) + one_item.initFrom(one_desc) negate_desc = reg.widget("negate") self.negate_item = negate_item = NodeItem() - negate_item.setWidgetDescription(negate_desc) - negate_item.setWidgetCategory(const_desc) - operator_desc = reg.category("Operators") + negate_item.initFrom(negate_desc) add_desc = reg.widget("add") self.nb_item = nb_item = NodeItem() - nb_item.setWidgetDescription(add_desc) - nb_item.setWidgetCategory(operator_desc) + nb_item.initFrom(add_desc) def test_linkitem(self): one_item = self.one_item diff --git a/orangecanvas/canvas/items/tests/test_nodeitem.py b/orangecanvas/canvas/items/tests/test_nodeitem.py index 4c747c7e3..11a433bd5 100644 --- a/orangecanvas/canvas/items/tests/test_nodeitem.py +++ b/orangecanvas/canvas/items/tests/test_nodeitem.py @@ -23,10 +23,7 @@ def setUp(self): self.add_desc = self.reg.widget("add") def test_nodeitem(self): - one_item = NodeItem() - one_item.setWidgetDescription(self.one_desc) - one_item.setWidgetCategory(self.const_desc) - + one_item = NodeItem.from_node_meta(self.one_desc) one_item.setTitle("Neo") self.assertEqual(one_item.title(), "Neo") @@ -50,17 +47,11 @@ def test_nodeitem(self): self.scene.addItem(one_item) one_item.setPos(100, 100) - negate_item = NodeItem() - negate_item.setWidgetDescription(self.negate_desc) - negate_item.setWidgetCategory(self.const_desc) - + negate_item = NodeItem.from_node_meta(self.negate_desc) self.scene.addItem(negate_item) negate_item.setPos(300, 100) - nb_item = NodeItem() - nb_item.setWidgetDescription(self.add_desc) - nb_item.setWidgetCategory(self.operator_desc) - + nb_item = NodeItem.from_node_meta(self.add_desc) self.scene.addItem(nb_item) nb_item.setPos(500, 100) @@ -104,29 +95,17 @@ def progress(): timer.stop() def test_nodeanchors(self): - one_item = NodeItem() - one_item.setWidgetDescription(self.one_desc) - one_item.setWidgetCategory(self.const_desc) - + one_item = NodeItem.from_node_meta(self.one_desc) one_item.setTitle("File Node") self.scene.addItem(one_item) one_item.setPos(100, 100) - negate_item = NodeItem() - negate_item.setWidgetDescription(self.negate_desc) - negate_item.setWidgetCategory(self.const_desc) - + negate_item = NodeItem.from_node_meta(self.negate_desc) self.scene.addItem(negate_item) negate_item.setPos(300, 100) - nb_item = NodeItem() - nb_item.setWidgetDescription(self.add_desc) - nb_item.setWidgetCategory(self.operator_desc) - - with self.assertRaises(ValueError): - one_item.newInputAnchor() - + nb_item = NodeItem.from_node_meta(self.add_desc) anchor = one_item.newOutputAnchor() self.assertIsInstance(anchor, AnchorPoint) @@ -203,8 +182,7 @@ def advance(): self.assertEqual(path, anchoritem.anchorPath()) def test_title_edit(self): - item = NodeItem() - item.setWidgetDescription(self.one_desc) + item = NodeItem.from_node_meta(self.one_desc) self.scene.addItem(item) item.setTitle("AA") item.setStatusMessage("BB") diff --git a/orangecanvas/canvas/scene.py b/orangecanvas/canvas/scene.py index 0c5a5aec7..b38f9b9be 100644 --- a/orangecanvas/canvas/scene.py +++ b/orangecanvas/canvas/scene.py @@ -5,49 +5,140 @@ """ import typing +import warnings from typing import Dict, List, Optional, Any, Type, Tuple, Union import logging import itertools - -from operator import attrgetter - from xml.sax.saxutils import escape from AnyQt.QtWidgets import QGraphicsScene, QGraphicsItem -from AnyQt.QtGui import QPainter, QColor, QFont +from AnyQt.QtGui import QColor, QFont from AnyQt.QtCore import ( - Qt, QPointF, QRectF, QSizeF, QLineF, QBuffer, QObject, QSignalMapper, - QParallelAnimationGroup, QT_VERSION + Qt, QPointF, QRectF, QSizeF, QLineF, QObject, QSignalMapper, ) -from AnyQt.QtSvg import QSvgGenerator from AnyQt.QtCore import pyqtSignal as Signal from ..registry import ( WidgetRegistry, WidgetDescription, CategoryDescription, - InputSignal, OutputSignal + InputSignal, OutputSignal, NAMED_COLORS ) from .. import scheme -from ..scheme import Scheme, SchemeNode, SchemeLink, BaseSchemeAnnotation +from ..scheme import Scheme, Node, Link, Annotation, MetaNode, InputNode, OutputNode +from ..gui.scene import GraphicsScene, UserInteraction from . import items from .items import NodeItem, LinkItem -from .items.annotationitem import Annotation +from .items.annotationitem import AnnotationItem from .layout import AnchorLayout +from ..scheme.element import Element +from ..utils.qinvoke import connect_with_context as qconnect if typing.TYPE_CHECKING: - from ..document.interactions import UserInteraction T = typing.TypeVar("T", bound=QGraphicsItem) __all__ = [ - "CanvasScene", "grab_svg" + "CanvasScene", ] log = logging.getLogger(__name__) -class CanvasScene(QGraphicsScene): +def Node_toolTipHelper(node: Node) -> str: + """ + A helper function for constructing a standard tooltip for the `node`. + """ + title = f"{escape(node.title)}" + if node.input_channels(): + inputs = [f"
  • {escape(inp.name)}
  • " for inp in node.input_channels()] + inputs = f'Inputs:' + else: + inputs = "No inputs" + + if node.output_channels(): + outputs = [f"
  • {escape(out.name)}
  • " for out in node.output_channels()] + outputs = f'Outputs:' + else: + outputs = "No outputs" + + tooltip = "
    ".join([title, inputs, outputs]) + style = "ul { margin-top: 1px; margin-bottom: 1px; }" + return f'{tooltip}' + + +class ItemDelegate(QObject): + def createGraphicsWidget(self, node: Node, scene: QGraphicsScene = None) -> NodeItem: + item = items.NodeItem() + item.setIcon(node.icon()) + item.setTitle(node.title) + item.setPos(QPointF(*node.position)) + item.setProcessingState(node.processing_state) + item.setProgress(node.progress) + + for message in node.state_messages(): + item.setStateMessage(message) + + item.setStatusMessage(node.status_message()) + c = qconnect( + node.position_changed, item, + lambda pos: item.setPos(QPointF(*pos)), + ) + item.__disconnect = c.disconnect + node.title_changed.connect(item.setTitle) + node.progress_changed.connect(item.setProgress) + node.processing_state_changed.connect(item.setProcessingState) + node.state_message_changed.connect(item.setStateMessage) + node.status_message_changed.connect(item.setStatusMessage) + + def update_io_channels(): + item.inputAnchorItem.setSignals(node.input_channels()) + item.inputAnchorItem.setVisible(bool(node.input_channels())) + item.outputAnchorItem.setSignals(node.output_channels()) + item.outputAnchorItem.setVisible(bool(node.output_channels())) + item.setToolTip(Node_toolTipHelper(node, )) + if isinstance(node, InputNode): + item.inputAnchorItem.setVisible(False) + elif isinstance(node, OutputNode): + item.outputAnchorItem.setVisible(False) + + node.input_channel_inserted.connect(update_io_channels) + node.input_channel_removed.connect(update_io_channels) + node.output_channel_inserted.connect(update_io_channels) + node.output_channel_removed.connect(update_io_channels) + update_io_channels() + color = self.backgroundColor(node) + if color.isValid(): + item.setColor(color) + return item + + def backgroundColor(self, node: Node) -> QColor: + desc = getattr(node, "description", None) + if desc is not None: + if desc.background: + background = NAMED_COLORS.get(desc.background, desc.background) + color = QColor(background) + if color.isValid(): + return color + return QColor(152, 158, 160) + + def setGraphicsWidgetData(self, item: NodeItem, node: Node): + pass + + def commitData(self, item, node: Node): + pass + + def destroyGraphicsWidget(self, item: NodeItem, node: Node): + item.__disconnect() + node.title_changed.disconnect(item.setTitle) + node.progress_changed.disconnect(item.setProgress) + node.processing_state_changed.disconnect(item.setProcessingState) + node.state_message_changed.disconnect(item.setStateMessage) + node.status_message_changed.connect(item.setStatusMessage) + item.deleteLater() + + +class CanvasScene(GraphicsScene): """ A Graphics Scene for displaying an :class:`~.scheme.Scheme` instance. """ @@ -66,10 +157,10 @@ class CanvasScene(QGraphicsScene): #: Signal emitted when a :class:`LinkItem` has been removed. link_item_removed = Signal(object) - #: Signal emitted when a :class:`Annotation` item has been added. + #: Signal emitted when a :class:`AnnotationItem` item has been added. annotation_added = Signal(object) - #: Signal emitted when a :class:`Annotation` item has been removed. + #: Signal emitted when a :class:`AnnotationItem` item has been removed. annotation_removed = Signal(object) #: Signal emitted when the position of a :class:`NodeItem` has changed. @@ -93,26 +184,23 @@ class CanvasScene(QGraphicsScene): def __init__(self, *args, **kwargs): # type: (Any, Any) -> None super().__init__(*args, **kwargs) - self.scheme = None # type: Optional[Scheme] - self.registry = None # type: Optional[WidgetRegistry] + self.root = None # type: Optional[MetaNode] + self.__registry = None # type: Optional[WidgetRegistry] # All node items self.__node_items = [] # type: List[NodeItem] - # Mapping from SchemeNodes to canvas items - self.__item_for_node = {} # type: Dict[SchemeNode, NodeItem] + # Mapping from Nodes to canvas items + self.__item_for_node = {} # type: Dict[Node, NodeItem] # All link items self.__link_items = [] # type: List[LinkItem] # Mapping from SchemeLinks to canvas items. - self.__item_for_link = {} # type: Dict[SchemeLink, LinkItem] + self.__item_for_link = {} # type: Dict[Link, LinkItem] # All annotation items - self.__annotation_items = [] # type: List[Annotation] + self.__annotation_items = [] # type: List[AnnotationItem] # Mapping from SchemeAnnotations to canvas items. - self.__item_for_annotation = {} # type: Dict[BaseSchemeAnnotation, Annotation] - - # Is the scene editable - self.editable = True + self.__item_for_annotation = {} # type: Dict[Annotation, AnnotationItem] # Anchor Layout self.__anchor_layout = AnchorLayout() @@ -140,7 +228,6 @@ def __init__(self, *args, **kwargs): self.link_activated_mapper.mappedObject.connect( lambda node: self.link_item_activated.emit(node) ) - self.__anchors_opened = False def clear_scene(self): # type: () -> None @@ -148,31 +235,32 @@ def clear_scene(self): # type: () -> None Clear (reset) the scene. """ if self.scheme is not None: - self.scheme.node_added.disconnect(self.add_node) - self.scheme.node_removed.disconnect(self.remove_node) + self.scheme.node_added.disconnect(self.__on_node_added) + self.scheme.node_removed.disconnect(self.__on_node_removed) - self.scheme.link_added.disconnect(self.add_link) - self.scheme.link_removed.disconnect(self.remove_link) + self.scheme.link_added.disconnect(self.__on_link_added) + self.scheme.link_removed.disconnect(self.__on_link_removed) - self.scheme.annotation_added.disconnect(self.add_annotation) - self.scheme.annotation_removed.disconnect(self.remove_annotation) + self.scheme.annotation_added.disconnect(self.__on_annotation_added) + self.scheme.annotation_removed.disconnect(self.__on_annotation_removed) # Remove all items to make sure all signals from scheme items # to canvas items are disconnected. - for annot in self.scheme.annotations: + for annot in self.root.annotations(): if annot in self.__item_for_annotation: self.remove_annotation(annot) - for link in self.scheme.links: + for link in self.root.links(): if link in self.__item_for_link: self.remove_link(link) - for node in self.scheme.nodes: + for node in self.root.nodes(): if node in self.__item_for_node: self.remove_node(node) self.scheme = None + self.root = None self.__node_items = [] self.__item_for_node = {} self.__link_items = [] @@ -186,8 +274,7 @@ def clear_scene(self): # type: () -> None self.clear() - def set_scheme(self, scheme): - # type: (Scheme) -> None + def set_scheme(self, scheme: Scheme, root: MetaNode = None): """ Set the scheme to display. Populates the scene with nodes and links already in the scheme. Any further change to the scheme will be @@ -195,31 +282,33 @@ def set_scheme(self, scheme): Parameters ---------- - scheme : :class:`~.scheme.Scheme` - + scheme: Scheme + root: MetaNode """ if self.scheme is not None: # Clear the old scheme self.clear_scene() - + root = root if root is not None else scheme.root() + self.root = root self.scheme = scheme + if self.scheme is not None: - self.scheme.node_added.connect(self.add_node) - self.scheme.node_removed.connect(self.remove_node) + self.scheme.node_added.connect(self.__on_node_added) + self.scheme.node_removed.connect(self.__on_node_removed) - self.scheme.link_added.connect(self.add_link) - self.scheme.link_removed.connect(self.remove_link) + self.scheme.link_added.connect(self.__on_link_added) + self.scheme.link_removed.connect(self.__on_link_removed) - self.scheme.annotation_added.connect(self.add_annotation) - self.scheme.annotation_removed.connect(self.remove_annotation) + self.scheme.annotation_added.connect(self.__on_annotation_added) + self.scheme.annotation_removed.connect(self.__on_annotation_removed) - for node in scheme.nodes: + for node in root.nodes(): self.add_node(node) - for link in scheme.links: + for link in root.links(): self.add_link(link) - for annot in scheme.annotations: + for annot in root.annotations(): self.add_annotation(annot) self.__anchor_layout.activate() @@ -229,9 +318,17 @@ def set_registry(self, registry): """ Set the widget registry. """ - # TODO: Remove/Deprecate. Is used only to get the category/background - # color. That should be part of the SchemeNode/WidgetDescription. - self.registry = registry + warnings.warn( + "`set_registry` is deprecated", DeprecationWarning, stacklevel=2 + ) + self.__registry = registry + + @property + def registry(self): + warnings.warn( + '`registry` is deprecated', DeprecationWarning, stacklevel=2 + ) + return self.__registry def set_anchor_layout(self, layout): """ @@ -319,68 +416,45 @@ def add_node_item(self, item): return item - def add_node(self, node): - # type: (SchemeNode) -> NodeItem + def __on_node_added(self, node: Node, parent: MetaNode): + if parent is self.root: + self.add_node(node) + + def __on_node_removed(self, node: Node, parent: MetaNode): + if parent is self.root: + self.remove_node(node) + + def add_node(self, node: Node) -> NodeItem: """ Add and return a default constructed :class:`.NodeItem` for a - :class:`SchemeNode` instance `node`. If the `node` is already in + :class:`Node` instance `node`. If the `node` is already in the scene do nothing and just return its item. - """ if node in self.__item_for_node: # Already added return self.__item_for_node[node] - item = self.new_node_item(node.description) - - if node.position: - pos = QPointF(*node.position) - item.setPos(pos) - - item.setTitle(node.title) - item.setProcessingState(node.processing_state) - item.setProgress(node.progress) + delegate = ItemDelegate() + item = delegate.createGraphicsWidget(node, self) item.inputAnchorItem.setAnchorOpen(self.__anchors_opened) item.outputAnchorItem.setAnchorOpen(self.__anchors_opened) - - for message in node.state_messages(): - item.setStateMessage(message) - - item.setStatusMessage(node.status_message()) - self.__item_for_node[node] = item - - node.position_changed.connect(self.__on_node_pos_changed) - node.title_changed.connect(item.setTitle) - node.progress_changed.connect(item.setProgress) - node.processing_state_changed.connect(item.setProcessingState) - node.state_message_changed.connect(item.setStateMessage) - node.status_message_changed.connect(item.setStatusMessage) - return self.add_node_item(item) def new_node_item(self, widget_desc, category_desc=None): - # type: (WidgetDescription, Optional[CategoryDescription]) -> NodeItem + # type: (Union[WidgetDescription, Node], Optional[CategoryDescription]) -> NodeItem """ Construct an new :class:`.NodeItem` from a `WidgetDescription`. Optionally also set `CategoryDescription`. - """ - item = items.NodeItem() - item.setWidgetDescription(widget_desc) - - if category_desc is None and self.registry and widget_desc.category: - category_desc = self.registry.category(widget_desc.category) - - if category_desc is None and self.registry is not None: - try: - category_desc = self.registry.category(widget_desc.category) - except KeyError: - pass - - if category_desc is not None: - item.setWidgetCategory(category_desc) - + warnings.warn( + "new_node_item is deprecated", DeprecationWarning, stacklevel=2 + ) + if isinstance(widget_desc, Node): + delegate = ItemDelegate() + item = delegate.createGraphicsWidget(widget_desc, self) + else: + item = items.NodeItem.from_node_meta(widget_desc) item.setAnimationEnabled(self.__node_animation_enabled) return item @@ -389,8 +463,6 @@ def remove_node_item(self, item): """ Remove `item` (:class:`.NodeItem`) from the scene. """ - desc = item.widget_description - self.activated_mapper.removeMappings(item) self.hovered_mapper.removeMappings(item) self.position_change_mapper.removeMappings(item) @@ -402,23 +474,16 @@ def remove_node_item(self, item): self.node_item_removed.emit(item) - def remove_node(self, node): - # type: (SchemeNode) -> None + def remove_node(self, node: Node) -> None: """ Remove the :class:`.NodeItem` instance that was previously - constructed for a :class:`SchemeNode` `node` using the `add_node` + constructed for a :class:`Node` `node` using the `add_node` method. - """ item = self.__item_for_node.pop(node) - - node.position_changed.disconnect(self.__on_node_pos_changed) - node.title_changed.disconnect(item.setTitle) - node.progress_changed.disconnect(item.setProgress) - node.processing_state_changed.disconnect(item.setProcessingState) - node.state_message_changed.disconnect(item.setStateMessage) - + delegate = ItemDelegate() self.remove_node_item(item) + delegate.destroyGraphicsWidget(item, node) def node_items(self): # type: () -> List[NodeItem] @@ -434,49 +499,50 @@ def add_link_item(self, item): """ self.link_activated_mapper.setMapping(item, item) item.activated.connect(self.link_activated_mapper.map) - if item.scene() is not self: self.addItem(item) - item.setFont(self.font()) self.__link_items.append(item) - self.link_item_added.emit(item) - self.__anchor_layout.invalidateLink(item) - return item - def add_link(self, scheme_link): - # type: (SchemeLink) -> LinkItem + def __on_link_added(self, link: Link, parent: MetaNode): + if parent is self.root: + self.add_link(link) + + def __on_link_removed(self, link: Link, parent: MetaNode): + if parent is self.root: + self.remove_link(link) + + def add_link(self, link: Link) -> LinkItem: """ Create and add a :class:`.LinkItem` instance for a - :class:`SchemeLink` instance. If the link is already in the scene + :class:`Link` instance. If the link is already in the scene do nothing and just return its :class:`.LinkItem`. - """ - if scheme_link in self.__item_for_link: - return self.__item_for_link[scheme_link] + if link in self.__item_for_link: + return self.__item_for_link[link] - source = self.__item_for_node[scheme_link.source_node] - sink = self.__item_for_node[scheme_link.sink_node] + source = self.__item_for_node[link.source_node] + sink = self.__item_for_node[link.sink_node] - item = self.new_link_item(source, scheme_link.source_channel, - sink, scheme_link.sink_channel) + item = self.new_link_item(source, link.source_channel, + sink, link.sink_channel) - item.setEnabled(scheme_link.is_enabled()) - scheme_link.enabled_changed.connect(item.setEnabled) + item.setEnabled(link.is_enabled()) + link.enabled_changed.connect(item.setEnabled) - if scheme_link.is_dynamic(): + if link.is_dynamic(): item.setDynamic(True) - item.setDynamicEnabled(scheme_link.is_dynamic_enabled()) - scheme_link.dynamic_enabled_changed.connect(item.setDynamicEnabled) + item.setDynamicEnabled(link.is_dynamic_enabled()) + link.dynamic_enabled_changed.connect(item.setDynamicEnabled) - item.setRuntimeState(scheme_link.runtime_state()) - scheme_link.state_changed.connect(item.setRuntimeState) + item.setRuntimeState(link.runtime_state()) + link.state_changed.connect(item.setRuntimeState) self.add_link_item(item) - self.__item_for_link[scheme_link] = item + self.__item_for_link[link] = item return item def new_link_item(self, source_item, source_channel, @@ -525,21 +591,19 @@ def remove_link_item(self, item): self.link_item_removed.emit(item) return item - def remove_link(self, scheme_link): - # type: (SchemeLink) -> None + def remove_link(self, link: Link) -> None: """ Remove a :class:`.LinkItem` instance that was previously constructed - for a :class:`SchemeLink` instance `link` using the `add_link` method. - + for a :class:`Link` instance `link` using the `add_link` method. """ - item = self.__item_for_link.pop(scheme_link) - scheme_link.enabled_changed.disconnect(item.setEnabled) + item = self.__item_for_link.pop(link) + link.enabled_changed.disconnect(item.setEnabled) - if scheme_link.is_dynamic(): - scheme_link.dynamic_enabled_changed.disconnect( + if link.is_dynamic(): + link.dynamic_enabled_changed.disconnect( item.setDynamicEnabled ) - scheme_link.state_changed.disconnect(item.setRuntimeState) + link.state_changed.disconnect(item.setRuntimeState) self.remove_link_item(item) def link_items(self): @@ -550,22 +614,29 @@ def link_items(self): return list(self.__link_items) def add_annotation_item(self, annotation): - # type: (Annotation) -> Annotation + # type: (AnnotationItem) -> AnnotationItem """ - Add an :class:`.Annotation` item to the scene. + Add an :class:`.AnnotationItem` item to the scene. """ self.__annotation_items.append(annotation) self.addItem(annotation) self.annotation_added.emit(annotation) return annotation + def __on_annotation_added(self, annot: Annotation, parent: MetaNode): + if parent is self.root: + self.add_annotation(annot) + + def __on_annotation_removed(self, annot: Annotation, parent: MetaNode): + if parent is self.root: + self.remove_annotation(annot) + def add_annotation(self, scheme_annot): - # type: (BaseSchemeAnnotation) -> Annotation + # type: (Annotation) -> AnnotationItem """ Create a new item for :class:`SchemeAnnotation` and add it to the scene. If the `scheme_annot` is already in the scene do nothing and just return its item. - """ if scheme_annot in self.__item_for_annotation: # Already added @@ -598,9 +669,9 @@ def add_annotation(self, scheme_annot): return item def remove_annotation_item(self, annotation): - # type: (Annotation) -> None + # type: (AnnotationItem) -> None """ - Remove an :class:`.Annotation` instance from the scene. + Remove an :class:`.AnnotationItem` instance from the scene. """ self.__annotation_items.remove(annotation) @@ -608,11 +679,10 @@ def remove_annotation_item(self, annotation): self.annotation_removed.emit(annotation) def remove_annotation(self, scheme_annotation): - # type: (BaseSchemeAnnotation) -> None + # type: (Annotation) -> None """ - Remove an :class:`.Annotation` instance that was previously added + Remove an :class:`.AnnotationItem` instance that was previously added using :func:`add_anotation`. - """ item = self.__item_for_annotation.pop(scheme_annotation) @@ -625,89 +695,62 @@ def remove_annotation(self, scheme_annotation): self.remove_annotation_item(item) def annotation_items(self): - # type: () -> List[Annotation] + # type: () -> List[AnnotationItem] """ - Return all :class:`.Annotation` items in the scene. + Return all :class:`.AnnotationItem` items in the scene. """ return self.__annotation_items.copy() def item_for_annotation(self, scheme_annotation): - # type: (BaseSchemeAnnotation) -> Annotation + # type: (Annotation) -> AnnotationItem return self.__item_for_annotation[scheme_annotation] def annotation_for_item(self, item): - # type: (Annotation) -> BaseSchemeAnnotation + # type: (AnnotationItem) -> Annotation rev = {v: k for k, v in self.__item_for_annotation.items()} return rev[item] - def commit_scheme_node(self, node): - """ - Commit the `node` into the scheme. - """ - if not self.editable: - raise Exception("Scheme not editable.") - - if node not in self.__item_for_node: - raise ValueError("No 'NodeItem' for node.") - - item = self.__item_for_node[node] - - try: - self.scheme.add_node(node) - except Exception: - log.error("An error occurred while committing node '%s'", - node, exc_info=True) - # Cleanup (remove the node item) - self.remove_node_item(item) - raise - - log.debug("Commited node '%s' from '%s' to '%s'" % \ - (node, self, self.scheme)) - - def commit_scheme_link(self, link): - """ - Commit a scheme link. - """ - if not self.editable: - raise Exception("Scheme not editable") - - if link not in self.__item_for_link: - raise ValueError("No 'LinkItem' for link.") - - self.scheme.add_link(link) - log.debug("Commited link '%s' from '%s' to '%s'" % \ - (link, self, self.scheme)) - def node_for_item(self, item): - # type: (NodeItem) -> SchemeNode + # type: (NodeItem) -> Node """ - Return the `SchemeNode` for the `item`. + Return the `Node` for the `item`. """ rev = dict([(v, k) for k, v in self.__item_for_node.items()]) return rev[item] def item_for_node(self, node): - # type: (SchemeNode) -> NodeItem + # type: (Node) -> NodeItem """ - Return the :class:`NodeItem` instance for a :class:`SchemeNode`. + Return the :class:`NodeItem` instance for a :class:`Node`. """ return self.__item_for_node[node] def link_for_item(self, item): - # type: (LinkItem) -> SchemeLink + # type: (LinkItem) -> Link """ - Return the `SchemeLink for `item` (:class:`LinkItem`). + Return the `Link for `item` (:class:`LinkItem`). """ rev = dict([(v, k) for k, v in self.__item_for_link.items()]) return rev[item] def item_for_link(self, link): - # type: (SchemeLink) -> LinkItem + # type: (Link) -> LinkItem """ - Return the :class:`LinkItem` for a :class:`SchemeLink` + Return the :class:`LinkItem` for a :class:`Link` """ return self.__item_for_link[link] + def item_for_element(self, element: Element) -> QGraphicsItem: + """Return the associated :class:`QGraphicsItem` for the `element`.""" + if isinstance(element, Node): + return self.__item_for_node[element] + elif isinstance(element, Link): + return self.__item_for_link[element] + elif isinstance(element, Annotation): + return self.__item_for_annotation[element] + else: + raise TypeError(element) + def selected_node_items(self): # type: () -> List[NodeItem] """ @@ -720,20 +763,12 @@ def selected_link_items(self): return [item for item in self.__link_items if item.isSelected()] def selected_annotation_items(self): - # type: () -> List[Annotation] + # type: () -> List[AnnotationItem] """ - Return the selected :class:`Annotation`'s + Return the selected :class:`AnnotationItem`'s """ return [item for item in self.__annotation_items if item.isSelected()] - def node_links(self, node_item): - # type: (NodeItem) -> List[LinkItem] - """ - Return all links from the `node_item` (:class:`NodeItem`). - """ - return self.node_output_links(node_item) + \ - self.node_input_links(node_item) - def node_output_links(self, node_item): # type: (NodeItem) -> List[LinkItem] """ @@ -750,19 +785,7 @@ def node_input_links(self, node_item): return [link for link in self.__link_items if link.sinkItem == node_item] - def neighbor_nodes(self, node_item): - # type: (NodeItem) -> List[NodeItem] - """ - Return a list of `node_item`'s (class:`NodeItem`) neighbor nodes. - """ - neighbors = list(map(attrgetter("sourceItem"), - self.node_input_links(node_item))) - - neighbors.extend(map(attrgetter("sinkItem"), - self.node_output_links(node_item))) - return neighbors - - def set_widget_anchors_open(self, enabled: bool): + def set_widget_anchors_open(self, enabled): if self.__anchors_opened == enabled: return self.__anchors_opened = enabled @@ -836,76 +859,8 @@ def mousePressEvent(self, event): 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() - - log.debug("Setting interaction '%s' to '%s'" % (handler, self)) - - self.user_interaction_handler = handler + super().set_user_interaction_handler(handler) if handler: if self.__node_animation_enabled: self.__animations_temporarily_disabled = True @@ -934,73 +889,3 @@ def font_from_dict(font_dict, font=None): font.setPixelSize(font_dict["size"]) return font - - -if QT_VERSION >= 0x50900 and \ - QSvgGenerator().metric(QSvgGenerator.PdmDevicePixelRatioScaled) == 1: - # QTBUG-63159 - class _QSvgGenerator(QSvgGenerator): # type: ignore - def metric(self, metric): - if metric == QSvgGenerator.PdmDevicePixelRatioScaled: - return int(1 * QSvgGenerator.devicePixelRatioFScale()) - else: - return super().metric(metric) - -else: - _QSvgGenerator = QSvgGenerator # type: ignore - - -def grab_svg(scene: QGraphicsScene) -> str: - """ - Return a SVG rendering of the scene contents. - - Parameters - ---------- - scene : :class:`CanvasScene` - - """ - svg_buffer = QBuffer() - gen = _QSvgGenerator() - views = scene.views() - if views: - screen = views[0].screen() - if screen is not None: - res = screen.physicalDotsPerInch() - gen.setResolution(int(res)) - gen.setOutputDevice(svg_buffer) - - items_rect = scene.itemsBoundingRect().adjusted(-10, -10, 10, 10) - - if items_rect.isNull(): - items_rect = QRectF(0, 0, 10, 10) - - width, height = items_rect.width(), items_rect.height() - rect_ratio = float(width) / height - - # Keep a fixed aspect ratio. - aspect_ratio = 1.618 - if rect_ratio > aspect_ratio: - height = int(height * rect_ratio / aspect_ratio) - else: - width = int(width * aspect_ratio / rect_ratio) - - target_rect = QRectF(0, 0, width, height) - source_rect = QRectF(0, 0, width, height) - source_rect.moveCenter(items_rect.center()) - - gen.setSize(target_rect.size().toSize()) - gen.setViewBox(target_rect) - - painter = QPainter(gen) - - # Draw background. - painter.setPen(Qt.NoPen) - painter.setBrush(scene.palette().base()) - painter.drawRect(target_rect) - - # Render the scene - scene.render(painter, target_rect, source_rect) - painter.end() - - buffer_str = bytes(svg_buffer.buffer()) - return buffer_str.decode("utf-8") diff --git a/orangecanvas/canvas/tests/test_layout.py b/orangecanvas/canvas/tests/test_layout.py index a63a73336..c9d79f9c4 100644 --- a/orangecanvas/canvas/tests/test_layout.py +++ b/orangecanvas/canvas/tests/test_layout.py @@ -32,17 +32,20 @@ def tearDown(self): def test_layout(self): one_desc, negate_desc, cons_desc = self.widget_desc() one_item = NodeItem() - one_item.setWidgetDescription(one_desc) + one_item.initFrom(one_desc) + # one_item.setWidgetDescription(one_desc) one_item.setPos(0, 150) self.scene.add_node_item(one_item) cons_item = NodeItem() - cons_item.setWidgetDescription(cons_desc) + cons_item.initFrom(cons_desc) + # cons_item.setWidgetDescription(cons_desc) cons_item.setPos(200, 0) self.scene.add_node_item(cons_item) negate_item = NodeItem() - negate_item.setWidgetDescription(negate_desc) + negate_item.initFrom(negate_desc) + # negate_item.setWidgetDescription(negate_desc) negate_item.setPos(200, 300) self.scene.add_node_item(negate_item) diff --git a/orangecanvas/canvas/tests/test_scene.py b/orangecanvas/canvas/tests/test_scene.py index f2748a03b..614c71258 100644 --- a/orangecanvas/canvas/tests/test_scene.py +++ b/orangecanvas/canvas/tests/test_scene.py @@ -30,10 +30,9 @@ def test_scene(self): """Test basic scene functionality. """ one_desc, negate_desc, cons_desc = self.widget_desc() - - one_item = items.NodeItem(one_desc) - negate_item = items.NodeItem(negate_desc) - cons_item = items.NodeItem(cons_desc) + one_item = items.NodeItem.from_node_meta(one_desc) + negate_item = items.NodeItem.from_node_meta(negate_desc) + cons_item = items.NodeItem.from_node_meta(cons_desc) one_item = self.scene.add_node_item(one_item) negate_item = self.scene.add_node_item(negate_item) @@ -123,6 +122,7 @@ def test_scene_with_scheme(self): for node, item in zip(nodes, node_items): self.assertIs(item, self.scene.item_for_node(node)) + self.assertIs(item, self.scene.item_for_element(node)) # Remove a widget test_scheme.remove_node(cons_node) @@ -150,86 +150,9 @@ def test_scene_with_scheme(self): test_scheme.add_link(link1) self.assertTrue(len(self.scene.link_items()) == 1) self.assertSequenceEqual(self.scene.link_items(), link_items) - self.qWait() - - def test_scheme_construction(self): - """Test construction (editing) of the scheme through the scene. - """ - test_scheme = scheme.Scheme() - self.scene.set_scheme(test_scheme) - - node_items = [] - link_items = [] - - self.scene.node_item_added.connect(node_items.append) - self.scene.node_item_removed.connect(node_items.remove) - self.scene.link_item_added.connect(link_items.append) - self.scene.link_item_removed.connect(link_items.remove) - - one_desc, negate_desc, cons_desc = self.widget_desc() - one_node = scheme.SchemeNode(one_desc) - - one_item = self.scene.add_node(one_node) - self.scene.commit_scheme_node(one_node) - - self.assertSequenceEqual(self.scene.node_items(), [one_item]) - self.assertSequenceEqual(node_items, [one_item]) - self.assertSequenceEqual(test_scheme.nodes, [one_node]) - - negate_node = scheme.SchemeNode(negate_desc) - cons_node = scheme.SchemeNode(cons_desc) - - negate_item = self.scene.add_node(negate_node) - cons_item = self.scene.add_node(cons_node) - - self.assertSequenceEqual(self.scene.node_items(), - [one_item, negate_item, cons_item]) - self.assertSequenceEqual(self.scene.node_items(), node_items) - - # The scheme is still the same. - self.assertSequenceEqual(test_scheme.nodes, [one_node]) - - # Remove items - self.scene.remove_node(negate_node) - self.scene.remove_node(cons_node) - - self.assertSequenceEqual(self.scene.node_items(), [one_item]) - self.assertSequenceEqual(node_items, [one_item]) - self.assertSequenceEqual(test_scheme.nodes, [one_node]) - - # Add them again this time also in the scheme. - negate_item = self.scene.add_node(negate_node) - cons_item = self.scene.add_node(cons_node) - - self.scene.commit_scheme_node(negate_node) - self.scene.commit_scheme_node(cons_node) - - self.assertSequenceEqual(self.scene.node_items(), - [one_item, negate_item, cons_item]) - self.assertSequenceEqual(self.scene.node_items(), node_items) - self.assertSequenceEqual(test_scheme.nodes, - [one_node, negate_node, cons_node]) - - link1 = scheme.SchemeLink(one_node, "value", negate_node, "value") - link2 = scheme.SchemeLink(negate_node, "result", cons_node, "first") - link_item1 = self.scene.add_link(link1) - link_item2 = self.scene.add_link(link2) - - self.assertSequenceEqual(self.scene.link_items(), - [link_item1, link_item2]) - self.assertSequenceEqual(self.scene.link_items(), link_items) - self.assertSequenceEqual(test_scheme.links, []) - - # Commit the links - self.scene.commit_scheme_link(link1) - self.scene.commit_scheme_link(link2) - - self.assertSequenceEqual(self.scene.link_items(), - [link_item1, link_item2]) - self.assertSequenceEqual(self.scene.link_items(), link_items) - self.assertSequenceEqual(test_scheme.links, - [link1, link2]) - + for link, item in zip([link1], link_items): + self.assertEqual(self.scene.item_for_element(link1), item) + self.assertEqual(self.scene.item_for_link(link1), item) self.qWait() def widget_desc(self): diff --git a/orangecanvas/canvas/tests/test_utils.py b/orangecanvas/canvas/tests/test_utils.py new file mode 100644 index 000000000..0b6d0e8a9 --- /dev/null +++ b/orangecanvas/canvas/tests/test_utils.py @@ -0,0 +1,14 @@ +from AnyQt.QtCore import QRectF +from AnyQt.QtWidgets import QGraphicsScene + +from orangecanvas.canvas.utils import grab_svg +from orangecanvas.gui.test import QAppTestCase + + +class TestUtils(QAppTestCase): + def test_grab_svg(self): + scene = QGraphicsScene() + scene.addRect(QRectF(0, 0, 10, 10)) + svg = grab_svg(scene) + self.assertIn("= 0x50900 and \ + QSvgGenerator().metric(QSvgGenerator.PdmDevicePixelRatioScaled) == 1: + # QTBUG-63159 + class _QSvgGenerator(QSvgGenerator): # type: ignore + def metric(self, metric): + if metric == QSvgGenerator.PdmDevicePixelRatioScaled: + return int(1 * QSvgGenerator.devicePixelRatioFScale()) + else: + return super().metric(metric) + +else: + _QSvgGenerator = QSvgGenerator # type: ignore + + +def grab_svg(scene: QGraphicsScene) -> str: + """ + Return a SVG rendering of the `scene`\\'s contents. + + Parameters + ---------- + scene : :class:`QGraphicScene` + """ + svg_buffer = QBuffer() + gen = _QSvgGenerator() + views = scene.views() + if views: + screen = views[0].screen() + if screen is not None: + res = screen.physicalDotsPerInch() + gen.setResolution(int(res)) + gen.setOutputDevice(svg_buffer) + + items_rect = scene.itemsBoundingRect().adjusted(-10, -10, 10, 10) + + if items_rect.isNull(): + items_rect = QRectF(0, 0, 10, 10) + + width, height = items_rect.width(), items_rect.height() + rect_ratio = float(width) / height + + # Keep a fixed aspect ratio. + aspect_ratio = 1.618 + if rect_ratio > aspect_ratio: + height = int(height * rect_ratio / aspect_ratio) + else: + width = int(width * aspect_ratio / rect_ratio) + + target_rect = QRectF(0, 0, width, height) + source_rect = QRectF(0, 0, width, height) + source_rect.moveCenter(items_rect.center()) + + gen.setSize(target_rect.size().toSize()) + gen.setViewBox(target_rect) + + painter = QPainter(gen) + + # Draw background. + painter.setPen(Qt.NoPen) + painter.setBrush(scene.palette().base()) + painter.drawRect(target_rect) + + # Render the scene + scene.render(painter, target_rect, source_rect) + painter.end() + + buffer_str = bytes(svg_buffer.buffer()) + return buffer_str.decode("utf-8") diff --git a/orangecanvas/document/commands.py b/orangecanvas/document/commands.py index 20a9e035e..c8b8d2bb0 100644 --- a/orangecanvas/document/commands.py +++ b/orangecanvas/document/commands.py @@ -2,19 +2,18 @@ Undo/Redo Commands """ -import typing from typing import Callable, Optional, Tuple, List, Any from AnyQt.QtWidgets import QUndoCommand -if typing.TYPE_CHECKING: - from ..scheme import ( - Scheme, SchemeNode, SchemeLink, BaseSchemeAnnotation, - SchemeTextAnnotation, SchemeArrowAnnotation - ) - Pos = Tuple[float, float] - Rect = Tuple[float, float, float, float] - Line = Tuple[Pos, Pos] +from ..scheme import ( + Workflow, Node, Link, MetaNode, Annotation, Text, Arrow, InputNode, + OutputNode, +) + +Pos = Tuple[float, float] +Rect = Tuple[float, float, float, float] +Line = Tuple[Pos, Pos] class UndoCommand(QUndoCommand): @@ -80,129 +79,169 @@ def from_QUndoCommand(qc: QUndoCommand, parent=None): class AddNodeCommand(UndoCommand): - def __init__(self, scheme, node, parent=None): - # type: (Scheme, SchemeNode, Optional[UndoCommand]) -> None + def __init__( + self, + scheme: Workflow, + node: Node, + parent_node: MetaNode, *, + parent=None + ) -> None: super().__init__("Add %s" % node.title, parent) self.scheme = scheme self.node = node + self.parent_node = parent_node def redo(self): - self.scheme.add_node(self.node) + self.scheme.add_node(self.node, self.parent_node) def undo(self): self.scheme.remove_node(self.node) +def input_links(node: Node): + parent = node.parent_node() + ilinks = parent.input_links(node) + if isinstance(node, InputNode): + parent_ = parent.parent_node() + imacro = parent_.find_links( + sink_node=parent, + sink_channel=node.input_channels()[0], + ) + else: + imacro = [] + return ilinks, imacro + + +def output_links(node: Node): + parent = node.parent_node() + olinks = parent.output_links(node) + if isinstance(node, OutputNode): + parent_ = parent.parent_node() + omacro = parent_.find_links( + source_node=parent, + source_channel=node.output_channels()[0], + ) + else: + omacro = [] + return olinks, omacro + + class RemoveNodeCommand(UndoCommand): - def __init__(self, scheme, node, parent=None): - # type: (Scheme, SchemeNode, Optional[UndoCommand]) -> None - super().__init__("Remove %s" % node.title, parent) + def __init__(self, scheme, node, parent_node, parent=None): + # type: (Workflow, Node, MetaNode, Optional[UndoCommand]) -> None + super().__init__("Remove %s" % node.title, parent=parent) self.scheme = scheme self.node = node + self.parent_node = parent_node self._index = -1 - links = scheme.input_links(self.node) + \ - scheme.output_links(self.node) - - for link in links: - RemoveLinkCommand(scheme, link, parent=self) + ilinks, imacro = input_links(node) + olinks, omacro = output_links(node) + for link in ilinks + olinks: + RemoveLinkCommand(scheme, link, parent_node, parent=self) + for link in imacro + omacro: + RemoveLinkCommand(scheme, link, parent_node.parent_node(), parent=self) def redo(self): # redo child commands super().redo() - self._index = self.scheme.nodes.index(self.node) + self._index = self.parent_node.nodes().index(self.node) self.scheme.remove_node(self.node) def undo(self): assert self._index != -1 - self.scheme.insert_node(self._index, self.node) + self.scheme.insert_node(self._index, self.node, self.parent_node) # Undo child commands super().undo() class AddLinkCommand(UndoCommand): - def __init__(self, scheme, link, parent=None): - # type: (Scheme, SchemeLink, Optional[UndoCommand]) -> None + def __init__(self, scheme, link, parent_node, parent=None): + # type: (Workflow, Link, MetaNode, Optional[UndoCommand]) -> None super().__init__("Add link", parent) self.scheme = scheme self.link = link + self.parent_node = parent_node def redo(self): - self.scheme.add_link(self.link) + self.scheme.add_link(self.link, self.parent_node) def undo(self): self.scheme.remove_link(self.link) class RemoveLinkCommand(UndoCommand): - def __init__(self, scheme, link, parent=None): - # type: (Scheme, SchemeLink, Optional[UndoCommand]) -> None + def __init__(self, scheme, link, parent_node, parent=None): + # type: (Workflow, Link, MetaNode, Optional[UndoCommand]) -> None super().__init__("Remove link", parent) self.scheme = scheme self.link = link + self.parent_node = parent_node self._index = -1 def redo(self): - self._index = self.scheme.links.index(self.link) + self._index = self.parent_node.links().index(self.link) self.scheme.remove_link(self.link) def undo(self): assert self._index != -1 - self.scheme.insert_link(self._index, self.link) + self.scheme.insert_link(self._index, self.link, self.parent_node) self._index = -1 class InsertNodeCommand(UndoCommand): def __init__( self, - scheme, # type: Scheme - new_node, # type: SchemeNode - old_link, # type: SchemeLink - new_links, # type: Tuple[SchemeLink, SchemeLink] - parent=None # type: Optional[UndoCommand] - ): # type: (...) -> None - super().__init__("Insert widget into link", parent) - - AddNodeCommand(scheme, new_node, parent=self) - RemoveLinkCommand(scheme, old_link, parent=self) + scheme: Workflow, + new_node: Node, + old_link: Link, + new_links: Tuple[Link, Link], + parent_node: MetaNode, + parent: Optional[UndoCommand] = None, + ) -> None: + super().__init__("Insert widget into link", parent=parent) + AddNodeCommand(scheme, new_node, parent_node, parent=self) + RemoveLinkCommand(scheme, old_link, parent_node, parent=self) for link in new_links: - AddLinkCommand(scheme, link, parent=self) + AddLinkCommand(scheme, link, parent_node, parent=self) class AddAnnotationCommand(UndoCommand): - def __init__(self, scheme, annotation, parent=None): - # type: (Scheme, BaseSchemeAnnotation, Optional[UndoCommand]) -> None + def __init__(self, scheme, annotation, parent_node, parent=None): + # type: (Workflow, Annotation, MetaNode, Optional[UndoCommand]) -> None super().__init__("Add annotation", parent) self.scheme = scheme self.annotation = annotation + self.parent_node = parent_node def redo(self): - self.scheme.add_annotation(self.annotation) + self.scheme.add_annotation(self.annotation, self.parent_node) def undo(self): self.scheme.remove_annotation(self.annotation) class RemoveAnnotationCommand(UndoCommand): - def __init__(self, scheme, annotation, parent=None): - # type: (Scheme, BaseSchemeAnnotation, Optional[UndoCommand]) -> None + def __init__(self, scheme, annotation, parent_node, parent=None): + # type: (Workflow, Annotation, MetaNode, Optional[UndoCommand]) -> None super().__init__("Remove annotation", parent) self.scheme = scheme self.annotation = annotation + self.parent_node = parent_node self._index = -1 def redo(self): - self._index = self.scheme.annotations.index(self.annotation) + self._index = self.parent_node.annotations().index(self.annotation) self.scheme.remove_annotation(self.annotation) def undo(self): assert self._index != -1 - self.scheme.insert_annotation(self._index, self.annotation) + self.scheme.insert_annotation(self._index, self.annotation, self.parent_node) self._index = -1 class MoveNodeCommand(UndoCommand): def __init__(self, scheme, node, old, new, parent=None): - # type: (Scheme, SchemeNode, Pos, Pos, Optional[UndoCommand]) -> None + # type: (Workflow, Node, Pos, Pos, Optional[UndoCommand]) -> None super().__init__("Move", parent) self.scheme = scheme self.node = node @@ -218,7 +257,7 @@ def undo(self): class ResizeCommand(UndoCommand): def __init__(self, scheme, item, new_geom, parent=None): - # type: (Scheme, SchemeTextAnnotation, Rect, Optional[UndoCommand]) -> None + # type: (Workflow, Text, Rect, Optional[UndoCommand]) -> None super().__init__("Resize", parent) self.scheme = scheme self.item = item @@ -234,7 +273,7 @@ def undo(self): class ArrowChangeCommand(UndoCommand): def __init__(self, scheme, item, new_line, parent=None): - # type: (Scheme, SchemeArrowAnnotation, Line, Optional[UndoCommand]) -> None + # type: (Workflow, Arrow, Line, Optional[UndoCommand]) -> None super().__init__("Move arrow", parent) self.scheme = scheme self.item = item @@ -251,8 +290,8 @@ def undo(self): class AnnotationGeometryChange(UndoCommand): def __init__( self, - scheme, # type: Scheme - annotation, # type: BaseSchemeAnnotation + scheme, # type: Workflow + annotation, # type: Annotation old, # type: Any new, # type: Any parent=None # type: Optional[UndoCommand] @@ -272,7 +311,7 @@ def undo(self): class RenameNodeCommand(UndoCommand): def __init__(self, scheme, node, old_name, new_name, parent=None): - # type: (Scheme, SchemeNode, str, str, Optional[UndoCommand]) -> None + # type: (Workflow, Node, str, str, Optional[UndoCommand]) -> None super().__init__("Rename", parent) self.scheme = scheme self.node = node @@ -289,8 +328,8 @@ def undo(self): class TextChangeCommand(UndoCommand): def __init__( self, - scheme, # type: Scheme - annotation, # type: SchemeTextAnnotation + scheme, # type: Workflow + annotation, # type: Text old_content, # type: str old_content_type, # type: str new_content, # type: str @@ -339,8 +378,8 @@ def undo(self): class SetWindowGroupPresets(UndoCommand): def __init__( self, - scheme: 'Scheme', - presets: List['Scheme.WindowGroup'], + scheme: 'Workflow', + presets: List['Workflow.WindowGroup'], parent: Optional[UndoCommand] = None, **kwargs ) -> None: diff --git a/orangecanvas/document/editlinksdialog.py b/orangecanvas/document/editlinksdialog.py index 960a65a32..1549bc677 100644 --- a/orangecanvas/document/editlinksdialog.py +++ b/orangecanvas/document/editlinksdialog.py @@ -27,14 +27,11 @@ Qt, QObject, QSize, QSizeF, QPointF, QRectF, QEvent ) -from ..scheme import compatible_channels +from ..scheme import Node, compatible_channels from ..registry import InputSignal, OutputSignal - -from ..resources import icon_loader from ..utils import type_str if typing.TYPE_CHECKING: - from ..scheme import SchemeNode IOPair = Tuple[OutputSignal, InputSignal] @@ -91,14 +88,12 @@ def __setupUi(self): self.setSizeGripEnabled(False) - def setNodes(self, source_node, sink_node): - # type: (SchemeNode, SchemeNode) -> None + def setNodes(self, source_node: Node, sink_node: Node) -> None: """ - Set the source/sink nodes (:class:`.SchemeNode` instances) + Set the source/sink nodes (:class:`.Node` instances) between which to edit the links. .. note:: This should be called before :func:`setLinks`. - """ self.scene.editWidget.setNodes(source_node, sink_node) @@ -584,10 +579,10 @@ def __init__(self, parent=None, direction=Qt.LeftToRight, self.layout().setAlignment(self.__channelLayout, Qt.AlignVCenter | channel_alignemnt) - self.node: Optional[SchemeNode] = None + self.node: Optional[Node] = None self.channels: Union[List[InputSignal], List[OutputSignal]] = [] if node is not None: - self.setSchemeNode(node) + self.setNode(node) def setIconSize(self, size): """ @@ -621,11 +616,12 @@ def icon(self): return QIcon(self.__icon) def setSchemeNode(self, node): - # type: (SchemeNode) -> None - """ - Set an instance of `SchemeNode`. The widget will be initialized - with its icon and channels. + self.setNode(node) + def setNode(self, node: Node) -> None: + """ + Set an instance of `Node`. The widget will be initialized with its + icon and channels. """ self.node = node channels: Union[List[InputSignal], List[OutputSignal]] @@ -635,10 +631,7 @@ def setSchemeNode(self, node): channels = node.input_channels() self.channels = channels - loader = icon_loader.from_description(node.description) - icon = loader.get(node.description.icon) - - self.setIcon(icon) + self.setIcon(node.icon()) label_template = ('
    ' '{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, )