diff --git a/Orange/canvas/canvas/items/linkitem.py b/Orange/canvas/canvas/items/linkitem.py index b6e1f65719b..d0dcf01cb70 100644 --- a/Orange/canvas/canvas/items/linkitem.py +++ b/Orange/canvas/canvas/items/linkitem.py @@ -12,23 +12,21 @@ QPainterPath, QTransform ) -from PyQt4.QtCore import Qt, QPointF, QEvent +from PyQt4.QtCore import Qt, QPointF, QRectF, QLineF, QEvent from .nodeitem import SHADOW_COLOR from .utils import stroke_path +from ...scheme import SchemeLink + class LinkCurveItem(QGraphicsPathItem): """ Link curve item. The main component of a :class:`LinkItem`. """ def __init__(self, parent): - QGraphicsPathItem.__init__(self, parent) - if not isinstance(parent, LinkItem): - raise TypeError("'LinkItem' expected") - + super().__init__(parent) self.setAcceptedMouseButtons(Qt.NoButton) - self.__canvasLink = parent self.setAcceptHoverEvents(True) self.shadow = QGraphicsDropShadowEffect( @@ -36,49 +34,51 @@ def __init__(self, parent): offset=QPointF(0, 0) ) - self.normalPen = QPen(QBrush(QColor("#9CACB4")), 2.0) - self.hoverPen = QPen(QBrush(QColor("#7D7D7D")), 2.1) - self.setPen(self.normalPen) self.setGraphicsEffect(self.shadow) self.shadow.setEnabled(False) self.__hover = False self.__enabled = True self.__shape = None + self.__curvepath = QPainterPath() + self.__curvepath_disabled = None + self.__pen = self.pen() + self.setPen(QPen(QBrush(QColor("#9CACB4")), 2.0)) - def linkItem(self): - """ - Return the :class:`LinkItem` instance this curve belongs to. - """ - return self.__canvasLink + def setCurvePath(self, path): + if path != self.__curvepath: + self.prepareGeometryChange() + self.__curvepath = QPainterPath(path) + self.__curvepath_disabled = None + self.__shape = None + self.__update() + + def curvePath(self): + return QPainterPath(self.__curvepath) def setHoverState(self, state): self.prepareGeometryChange() - self.__shape = None self.__hover = state self.__update() def setLinkEnabled(self, state): self.prepareGeometryChange() - self.__shape = None self.__enabled = state self.__update() def isLinkEnabled(self): return self.__enabled - def setCurvePenSet(self, pen, hoverPen): - self.prepareGeometryChange() - if pen is not None: - self.normalPen = pen - if hoverPen is not None: - self.hoverPen = hoverPen - self.__shape = None - self.__update() + def setPen(self, pen): + if self.__pen != pen: + self.prepareGeometryChange() + self.__pen = QPen(pen) + self.__shape = None + super().setPen(self.__pen) def shape(self): if self.__shape is None: - path = self.path() + path = self.curvePath() pen = QPen(QBrush(Qt.black), max(self.pen().widthF(), 20), Qt.SolidLine) @@ -87,26 +87,154 @@ def shape(self): def setPath(self, path): self.__shape = None - QGraphicsPathItem.setPath(self, path) + super().setPath(path) def __update(self): shadow_enabled = self.__hover if self.shadow.isEnabled() != shadow_enabled: self.shadow.setEnabled(shadow_enabled) - + basecurve = self.__curvepath link_enabled = self.__enabled if link_enabled: - pen_style = Qt.SolidLine + path = basecurve else: - pen_style = Qt.DashLine + if self.__curvepath_disabled is None: + self.__curvepath_disabled = path_link_disabled(basecurve) + path = self.__curvepath_disabled - if self.__hover: - pen = self.hoverPen - else: - pen = self.normalPen + self.setPath(path) + + +def bezier_subdivide(cp, t): + """ + Subdivide a cubic bezier curve defined by the control points `cp`. + + Parameters + ---------- + cp : List[QPointF] + The control points for a cubic bezier curve. + t : float + The cut point; a value between 0 and 1. + + Returns + ------- + cp : Tuple[List[QPointF], List[QPointF]] + Two lists of new control points for the new left and right part + respectively. + """ + # http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-sub.html + c00, c01, c02, c03 = cp + + c10 = c00 * (1 - t) + c01 * t + c11 = c01 * (1 - t) + c02 * t + c12 = c02 * (1 - t) + c03 * t + + c20 = c10 * (1 - t) + c11 * t + c21 = c11 * (1 - t) + c12 * t + + c30 = c20 * (1 - t) + c21 * t + + first = [c00, c10, c20, c30] + second = [c30, c21, c12, c03] + return first, second + + +def qpainterpath_simple_split(path, t): + """ + Split a QPainterPath defined simple curve. + + The path must be either empty or composed of a single LineToElement or + CurveToElement. + + Parameters + ---------- + path : QPainterPath + + t : float + Point where to split specified as a percentage along the path + + Returns + ------- + splitpath: Tuple[QPainterPath, QPainterPath] + A pair of QPainterPaths + """ + assert path.elementCount() > 0 + el0 = path.elementAt(0) + assert el0.type == QPainterPath.MoveToElement + if path.elementCount() == 1: + p1 = QPainterPath() + p1.moveTo(el0.x, el0.y) + return p1, QPainterPath(p1) + + el1 = path.elementAt(1) + if el1.type == QPainterPath.LineToElement: + pointat = path.pointAtPercent(t) + l1 = QLineF(el0.x, el0.y, pointat.x(), pointat.y()) + l2 = QLineF(pointat.x(), pointat.y(), el1.x, el1.y) + p1 = QPainterPath() + p2 = QPainterPath() + p1.addLine(l1) + p2.addLine(l2) + return p1, p2 + elif el1.type == QPainterPath.CurveToElement: + c0, c1, c2, c3 = el0, el1, path.elementAt(2), path.elementAt(3) + assert all(el.type == QPainterPath.CurveToDataElement + for el in [c2, c3]) + cp = [QPointF(el.x, el.y) for el in [c0, c1, c2, c3]] + first, second = bezier_subdivide(cp, t) + p1, p2 = QPainterPath(), QPainterPath() + p1.moveTo(first[0]) + p1.cubicTo(*first[1:]) + p2.moveTo(second[0]) + p2.cubicTo(*second[1:]) + return p1, p2 + else: + assert False + + +def path_link_disabled(basepath): + """ + Return a QPainterPath 'styled' to indicate a 'disabled' link. - pen.setStyle(pen_style) - self.setPen(pen) + A disabled link is displayed with a single disconnection symbol in the + middle (--||--) + + Parameters + ---------- + basepath : QPainterPath + The base path (a simple curve spine). + + Returns + ------- + path : QPainterPath + A 'styled' link path + """ + segmentlen = basepath.length() + px = 5 + + if segmentlen < 10: + return QPainterPath(basepath) + + t = (px / 2) / segmentlen + p1, _ = qpainterpath_simple_split(basepath, 0.50 - t) + _, p2 = qpainterpath_simple_split(basepath, 0.50 + t) + + angle = -basepath.angleAtPercent(0.5) + 90 + angler = math.radians(angle) + normal = QPointF(math.cos(angler), math.sin(angler)) + + end1 = p1.currentPosition() + start2 = QPointF(p2.elementAt(0).x, p2.elementAt(0).y) + p1.moveTo(start2.x(), start2.y()) + p1.addPath(p2) + + def QPainterPath_addLine(path, line): + path.moveTo(line.p1()) + path.lineTo(line.p2()) + + QPainterPath_addLine(p1, QLineF(end1 - normal * 3, end1 + normal * 3)) + QPainterPath_addLine(p1, QLineF(start2 - normal * 3, start2 + normal * 3)) + return p1 class LinkAnchorIndicator(QGraphicsEllipseItem): @@ -117,21 +245,28 @@ class LinkAnchorIndicator(QGraphicsEllipseItem): """ def __init__(self, *args): QGraphicsEllipseItem.__init__(self, *args) - self.setRect(-3, -3, 6, 6) + self.setRect(-3.5, -3.5, 7., 7.) self.setPen(QPen(Qt.NoPen)) - self.normalBrush = QBrush(QColor("#9CACB4")) - self.hoverBrush = QBrush(QColor("#7D7D7D")) - self.setBrush(self.normalBrush) + self.setBrush(QBrush(QColor("#9CACB4"))) self.__hover = False def setHoverState(self, state): - """The hover state is set by the LinkItem. """ - self.__hover = state - if state: - self.setBrush(self.hoverBrush) - else: - self.setBrush(self.normalBrush) + The hover state is set by the LinkItem. + """ + if self.__hover != state: + self.__hover = state + self.update() + + def paint(self, painter, option, widget=None): + brush = self.brush() + + if self.__hover: + brush = QBrush(brush.color().darker(110)) + + painter.setBrush(brush) + painter.setPen(self.pen()) + painter.drawEllipse(self.rect()) class LinkItem(QGraphicsObject): @@ -151,6 +286,17 @@ class LinkItem(QGraphicsObject): #: Z value of the item Z_VALUE = 0 + #: Runtime link state value + #: These are pulled from SchemeLink.State for ease of binding to it's + #: state + State = SchemeLink.State + #: Link is empty; the source node does not have any value on output + Empty = SchemeLink.Empty + #: Link is active; the source node has a valid value on output + Active = SchemeLink.Active + #: The link is pending; the sink node is scheduled for update + Pending = SchemeLink.Pending + def __init__(self, *args): self.__boundingRect = None QGraphicsObject.__init__(self, *args) @@ -179,10 +325,11 @@ def __init__(self, *args): self.__dynamic = False self.__dynamicEnabled = False - + self.__state = LinkItem.Empty self.hover = False self.prepareGeometryChange() + self.__updatePen() self.__boundingRect = None def setSourceItem(self, item, anchor=None): @@ -362,7 +509,7 @@ def __updateCurve(self): sink_pos - QPointF(cp_offset, 0), sink_pos) - self.curveItem.setPath(path) + self.curveItem.setCurvePath(path) self.sourceIndicator.setPos(source_pos) self.sinkIndicator.setPos(sink_pos) self.__updateText() @@ -389,7 +536,7 @@ def __updateText(self): self.linkTextItem.setPlainText(text) - path = self.curveItem.path() + path = self.curveItem.curvePath() if not path.isEmpty(): center = path.pointAtPercent(0.5) angle = path.angleAtPercent(0.5) @@ -418,6 +565,7 @@ def setHoverState(self, state): self.sinkIndicator.setHoverState(state) self.sourceIndicator.setHoverState(state) self.curveItem.setHoverState(state) + self.__updatePen() def hoverEnterEvent(self, event): # Hover enter event happens when the mouse enters any child object @@ -500,6 +648,26 @@ def isDynamic(self): """ return self.__dynamic + def setRuntimeState(self, state): + """ + Style the link appropriate to the LinkItem.State + + Parameters + ---------- + state : LinkItem.State + """ + if self.__state != state: + self.__state = state + + if state & LinkItem.Pending: + self.sinkIndicator.setBrush(QBrush(Qt.yellow)) + else: + self.sinkIndicator.setBrush(QBrush(QColor("#9CACB4"))) + self.__updatePen() + + def runtimeState(self): + return self.__state + def __updatePen(self): self.prepareGeometryChange() self.__boundingRect = None @@ -515,4 +683,17 @@ def __updatePen(self): normal = QPen(QBrush(QColor("#9CACB4")), 2.0) hover = QPen(QBrush(QColor("#7D7D7D")), 2.1) - self.curveItem.setCurvePenSet(normal, hover) + if self.__state & LinkItem.Active: + pen_style = Qt.SolidLine + else: + pen_style = Qt.DashLine + + normal.setStyle(pen_style) + hover.setStyle(pen_style) + + if self.hover: + pen = hover + else: + pen = normal + + self.curveItem.setPen(pen) diff --git a/Orange/canvas/canvas/scene.py b/Orange/canvas/canvas/scene.py index 82d566c9f2c..3d30283e4d5 100644 --- a/Orange/canvas/canvas/scene.py +++ b/Orange/canvas/canvas/scene.py @@ -442,6 +442,9 @@ def add_link(self, scheme_link): item.setDynamicEnabled(scheme_link.dynamic_enabled) scheme_link.dynamic_enabled_changed.connect(item.setDynamicEnabled) + item.setRuntimeState(scheme_link.runtime_state()) + scheme_link.state_changed.connect(item.setRuntimeState) + self.add_link_item(item) self.__item_for_link[scheme_link] = item return item @@ -513,7 +516,7 @@ def remove_link(self, scheme_link): scheme_link.dynamic_enabled_changed.disconnect( item.setDynamicEnabled ) - + scheme_link.state_changed.disconnect(item.setRuntimeState) self.remove_link_item(item) def link_items(self): diff --git a/Orange/canvas/scheme/link.py b/Orange/canvas/scheme/link.py index f5f6c00cee1..e55f0de724a 100644 --- a/Orange/canvas/scheme/link.py +++ b/Orange/canvas/scheme/link.py @@ -4,6 +4,7 @@ =========== """ +import enum from PyQt4.QtCore import QObject from PyQt4.QtCore import pyqtSignal as Signal @@ -76,6 +77,23 @@ class SchemeLink(QObject): #: The link dynamic enabled state has changed. dynamic_enabled_changed = Signal(bool) + #: Runtime link state has changed + state_changed = Signal(int) + + class State(enum.IntEnum): + """ + Flags indicating the runtime state of a link + """ + #: A link is empty when it has no value on it + Empty = 0 + #: A link is active when the source node provides a value on output + Active = 1 + #: A link is pending when it's sink node has not yet been notified + #: of a change (note that Empty|Pending is a valid state) + Pending = 2 + + Empty, Active, Pending = State + def __init__(self, source_node, source_channel, sink_node, sink_channel, enabled=True, properties=None, parent=None): @@ -108,6 +126,7 @@ def __init__(self, source_node, source_channel, self.__enabled = enabled self.__dynamic_enabled = False + self.__state = SchemeLink.Empty self.__tool_tip = "" self.properties = properties or {} @@ -166,6 +185,26 @@ def dynamic_enabled(self): dynamic_enabled = Property(bool, fget=dynamic_enabled, fset=set_dynamic_enabled) + def set_runtime_state(self, state): + """ + Set the link's runtime state. + + Parameters + ---------- + state : SchemeLink.State + """ + if self.__state != state: + self.__state = state + self.state_changed.emit(state) + + def runtime_state(self): + """ + Returns + ------- + state : SchemeLink.State + """ + return self.__state + def set_tool_tip(self, tool_tip): """ Set the link tool tip. diff --git a/Orange/canvas/scheme/signalmanager.py b/Orange/canvas/scheme/signalmanager.py index b49871bc578..e9ecb8f0a74 100644 --- a/Orange/canvas/scheme/signalmanager.py +++ b/Orange/canvas/scheme/signalmanager.py @@ -20,7 +20,7 @@ from PyQt4.QtCore import pyqtSignal as Signal -from .scheme import SchemeNode +from .scheme import SchemeNode, SchemeLink from functools import reduce log = logging.getLogger(__name__) @@ -185,6 +185,7 @@ def on_node_added(self, node): def link_added(self, link): # push all current source values to the sink + link.set_runtime_state(SchemeLink.Empty) if link.enabled: log.info("Link added (%s). Scheduling signal data update.", link) self._schedule(self.signals_on_link(link)) @@ -196,6 +197,7 @@ def link_removed(self, link): # purge all values in sink's queue log.info("Link removed (%s). Scheduling signal data purge.", link) self.purge_link(link) + link.enabled_changed.disconnect(self.link_enabled_changed) def link_enabled_changed(self, enabled): if enabled: @@ -223,7 +225,15 @@ def link_contents(self, link): """ node, channel = link.source_node, link.source_channel - return self._node_outputs[node][channel] + if node in self._node_outputs: + return self._node_outputs[node][channel] + else: + # if the the node was already removed it's tracked outputs in + # _node_outputs are cleared, however the final 'None' signal + # deliveries for the link are left in the _input_queue. + pending = [sig for sig in self._input_queue + if sig.link is link] + return {sig.id: sig.value for sig in pending} def send(self, node, channel, value, id): """ @@ -260,28 +270,20 @@ def _schedule(self, signals): """ self._input_queue.extend(signals) + for link in {sig.link for sig in signals}: + # update the SchemeLink's runtime state flags + contents = self.link_contents(link) + if any(value is not None for value in contents.values()): + state = SchemeLink.Active + else: + state = SchemeLink.Empty + link.set_runtime_state(state | SchemeLink.Pending) + if signals: self.updatesPending.emit() self._update() - def _update_links(self, source_node=None, source_channel=None, - sink_node=None, sink_channel=None): - """ - Schedule update of all enabled links matching the query. - - See :ref:`Scheme.find_links` for description of parameters. - - """ - links = self.scheme().find_links(source_node=source_node, - source_channel=source_channel, - sink_node=sink_node, - sink_channel=sink_channel) - links = list(filter(is_enabled, links)) - - signals = reduce(add, self.signals_on_link, []) - self._schedule(signals) - def _update_link(self, link): """ Schedule update of a single link. @@ -328,7 +330,12 @@ def process_node(self, node): log.debug("Processing %r, sending %i signals.", 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) + assert ({sig.link for sig in self._input_queue} + .intersection({sig.link for sig in signals_in}) == set([])) self.processingStarted.emit() self.processingStarted[SchemeNode].emit(node) try: diff --git a/Orange/canvas/scheme/widgetsscheme.py b/Orange/canvas/scheme/widgetsscheme.py index 9b12ae70501..c7631b2f94f 100644 --- a/Orange/canvas/scheme/widgetsscheme.py +++ b/Orange/canvas/scheme/widgetsscheme.py @@ -33,7 +33,7 @@ from PyQt4.QtCore import pyqtSignal as Signal from .signalmanager import SignalManager, compress_signals, can_enable_dynamic -from .scheme import Scheme, SchemeNode +from .scheme import Scheme, SchemeNode, SchemeLink from .node import UserMessage from ..utils import name_lookup from ..resources import icon_loader