source: orange/Orange/OrangeCanvas/document/interactions.py @ 11443:ff959d88f2d9

Revision 11443:ff959d88f2d9, 41.6 KB checked in by Ales Erjavec <ales.erjavec@…>, 13 months ago (diff)

Added docstring documentation for canvas interactions.

Line 
1"""
2=========================
3User Interaction Handlers
4=========================
5
6User interaction handlers for a :class:`~.SchemeEditWidget`.
7
8User interactions encapsulate the logic of user interactions with the
9scheme document.
10
11All interactions are subclasses of :class:`UserInteraction`.
12
13
14"""
15
16import logging
17
18from PyQt4.QtGui import (
19    QApplication, QGraphicsRectItem, QPen, QBrush, QColor, QFontMetrics
20)
21
22from PyQt4.QtCore import (
23    Qt, QObject, QCoreApplication, QSizeF, QPointF, QRect, QRectF, QLineF
24)
25
26from PyQt4.QtCore import pyqtSignal as Signal
27
28from ..registry.qt import QtWidgetRegistry
29from .. import scheme
30from ..canvas import items
31from ..canvas.items import controlpoints
32from ..gui.quickhelp import QuickHelpTipEvent
33from . import commands
34
35log = logging.getLogger(__name__)
36
37
38class UserInteraction(QObject):
39    """
40    Base class for user interaction handlers.
41
42    Parameters
43    ----------
44    document : :class:`~.SchemeEditWidget`
45        An scheme editor instance with which the user is interacting.
46    parent : :class:`QObject`, optional
47        A parent QObject
48    deleteOnEnd : bool, optional
49        Should the UserInteraction be deleted when it finishes (``True``
50        by default).
51
52    """
53    # Cancel reason flags
54
55    #: No specified reason
56    NoReason = 0
57    #: User canceled the operation (e.g. pressing ESC)
58    UserCancelReason = 1
59    #: Another interaction was set
60    InteractionOverrideReason = 3
61    #: An internal error occurred
62    ErrorReason = 4
63    #: Other (unspecified) reason
64    OtherReason = 5
65
66    #: Emitted when the interaction is set on the scene.
67    started = Signal()
68
69    #: Emitted when the interaction finishes successfully.
70    finished = Signal()
71
72    #: Emitted when the interaction ends (canceled or finished)
73    ended = Signal()
74
75    #: Emitted when the interaction is canceled.
76    canceled = Signal([], [int])
77
78    def __init__(self, document, parent=None, deleteOnEnd=True):
79        QObject.__init__(self, parent)
80        self.document = document
81        self.scene = document.scene()
82        self.scheme = document.scheme()
83        self.deleteOnEnd = deleteOnEnd
84
85        self.cancelOnEsc = False
86
87        self.__finished = False
88        self.__canceled = False
89        self.__cancelReason = self.NoReason
90
91    def start(self):
92        """
93        Start the interaction. This is called by the :class:`CanvasScene` when
94        the interaction is installed.
95
96        .. note:: Must be called from subclass implementations.
97
98        """
99        self.started.emit()
100
101    def end(self):
102        """
103        Finish the interaction. Restore any leftover state in this method.
104
105        .. note:: This gets called from the default :func:`cancel`
106                  implementation.
107
108        """
109        self.__finished = True
110
111        if self.scene.user_interaction_handler is self:
112            self.scene.set_user_interaction_handler(None)
113
114        if self.__canceled:
115            self.canceled.emit()
116            self.canceled[int].emit(self.__cancelReason)
117        else:
118            self.finished.emit()
119
120        self.ended.emit()
121
122        if self.deleteOnEnd:
123            self.deleteLater()
124
125    def cancel(self, reason=OtherReason):
126        """
127        Cancel the interaction with `reason`.
128        """
129
130        self.__canceled = True
131        self.__cancelReason = reason
132
133        self.end()
134
135    def isFinished(self):
136        """
137        Is the interaction finished.
138        """
139        return self.__finished
140
141    def isCanceled(self):
142        """
143        Was the interaction canceled.
144        """
145        return self.__canceled
146
147    def cancelReason(self):
148        """
149        Return the reason the interaction was canceled.
150        """
151        return self.__cancelReason
152
153    def mousePressEvent(self, event):
154        """
155        Handle a `QGraphicsScene.mousePressEvent`.
156        """
157        return False
158
159    def mouseMoveEvent(self, event):
160        """
161        Handle a `GraphicsScene.mouseMoveEvent`.
162        """
163        return False
164
165    def mouseReleaseEvent(self, event):
166        """
167        Handle a `QGraphicsScene.mouseReleaseEvent`.
168        """
169        return False
170
171    def mouseDoubleClickEvent(self, event):
172        """
173        Handle a `QGraphicsScene.mouseDoubleClickEvent`.
174        """
175        return False
176
177    def keyPressEvent(self, event):
178        """
179        Handle a `QGraphicsScene.keyPressEvent`
180        """
181        if self.cancelOnEsc and event.key() == Qt.Key_Escape:
182            self.cancel(self.UserCancelReason)
183        return False
184
185    def keyReleaseEvent(self, event):
186        """
187        Handle a `QGraphicsScene.keyPressEvent`
188        """
189        return False
190
191
192class NoPossibleLinksError(ValueError):
193    pass
194
195
196def reversed_arguments(func):
197    """
198    Return a function with reversed argument order.
199    """
200    def wrapped(*args):
201        return func(*reversed(args))
202    return wrapped
203
204
205class NewLinkAction(UserInteraction):
206    """
207    User drags a new link from an existing `NodeAnchorItem` to create
208    a connection between two existing nodes or to a new node if the release
209    is over an empty area, in which case a quick menu for new node selection
210    is presented to the user.
211
212    """
213    # direction of the drag
214    FROM_SOURCE = 1
215    FROM_SINK = 2
216
217    def __init__(self, document, *args, **kwargs):
218        UserInteraction.__init__(self, document, *args, **kwargs)
219        self.source_item = None
220        self.sink_item = None
221        self.from_item = None
222        self.direction = None
223
224        # An `NodeItem` currently under the mouse as a possible
225        # link drop target.
226        self.current_target_item = None
227        # A temporary `LinkItem` used while dragging.
228        self.tmp_link_item = None
229        # An temporary `AnchorPoint` inserted into `current_target_item`
230        self.tmp_anchor_point = None
231        # An `AnchorPoint` following the mouse cursor
232        self.cursor_anchor_point = None
233
234    def remove_tmp_anchor(self):
235        """
236        Remove a temporary anchor point from the current target item.
237        """
238        if self.direction == self.FROM_SOURCE:
239            self.current_target_item.removeInputAnchor(self.tmp_anchor_point)
240        else:
241            self.current_target_item.removeOutputAnchor(self.tmp_anchor_point)
242        self.tmp_anchor_point = None
243
244    def create_tmp_anchor(self, item):
245        """
246        Create a new tmp anchor at the `item` (:class:`NodeItem`).
247        """
248        assert(self.tmp_anchor_point is None)
249        if self.direction == self.FROM_SOURCE:
250            self.tmp_anchor_point = item.newInputAnchor()
251        else:
252            self.tmp_anchor_point = item.newOutputAnchor()
253
254    def can_connect(self, target_item):
255        """
256        Is the connection between `self.from_item` (item where the drag
257        started) and `target_item` possible.
258
259        """
260        node1 = self.scene.node_for_item(self.from_item)
261        node2 = self.scene.node_for_item(target_item)
262
263        if self.direction == self.FROM_SOURCE:
264            return bool(self.scheme.propose_links(node1, node2))
265        else:
266            return bool(self.scheme.propose_links(node2, node1))
267
268    def set_link_target_anchor(self, anchor):
269        """
270        Set the temp line target anchor.
271        """
272        if self.direction == self.FROM_SOURCE:
273            self.tmp_link_item.setSinkItem(None, anchor)
274        else:
275            self.tmp_link_item.setSourceItem(None, anchor)
276
277    def target_node_item_at(self, pos):
278        """
279        Return a suitable :class:`NodeItem` at position `pos` on which
280        a link can be dropped.
281
282        """
283        # Test for a suitable `NodeAnchorItem` or `NodeItem` at pos.
284        if self.direction == self.FROM_SOURCE:
285            anchor_type = items.SinkAnchorItem
286        else:
287            anchor_type = items.SourceAnchorItem
288
289        item = self.scene.item_at(pos, (anchor_type, items.NodeItem))
290
291        if isinstance(item, anchor_type):
292            item = item.parentNodeItem()
293
294        return item
295
296    def mousePressEvent(self, event):
297        anchor_item = self.scene.item_at(event.scenePos(),
298                                         items.NodeAnchorItem,
299                                         buttons=Qt.LeftButton)
300        if anchor_item and event.button() == Qt.LeftButton:
301            # Start a new link starting at item
302            self.from_item = anchor_item.parentNodeItem()
303            if isinstance(anchor_item, items.SourceAnchorItem):
304                self.direction = NewLinkAction.FROM_SOURCE
305                self.source_item = self.from_item
306            else:
307                self.direction = NewLinkAction.FROM_SINK
308                self.sink_item = self.from_item
309
310            event.accept()
311
312            helpevent = QuickHelpTipEvent(
313                self.tr("Create a new link"),
314                self.tr('<h3>Create new link</h3>'
315                        '<p>Drag a link to an existing node or release on '
316                        'an empty spot to create a new node.</p>'
317#                        '<a href="help://orange-canvas/create-new-links">'
318#                        'More ...</a>'
319                        )
320            )
321            QCoreApplication.postEvent(self.document, helpevent)
322
323            return True
324        else:
325            # Whoever put us in charge did not know what he was doing.
326            self.cancel(self.ErrorReason)
327            return False
328
329    def mouseMoveEvent(self, event):
330        if not self.tmp_link_item:
331            # On first mouse move event create the temp link item and
332            # initialize it to follow the `cursor_anchor_point`.
333            self.tmp_link_item = items.LinkItem()
334            # An anchor under the cursor for the duration of this action.
335            self.cursor_anchor_point = items.AnchorPoint()
336            self.cursor_anchor_point.setPos(event.scenePos())
337
338            # Set the `fixed` end of the temp link (where the drag started).
339            if self.direction == self.FROM_SOURCE:
340                self.tmp_link_item.setSourceItem(self.source_item)
341            else:
342                self.tmp_link_item.setSinkItem(self.sink_item)
343
344            self.set_link_target_anchor(self.cursor_anchor_point)
345            self.scene.addItem(self.tmp_link_item)
346
347        # `NodeItem` at the cursor position
348        item = self.target_node_item_at(event.scenePos())
349
350        if self.current_target_item is not None and \
351                (item is None or item is not self.current_target_item):
352            # `current_target_item` is no longer under the mouse cursor
353            # (was replaced by another item or the the cursor was moved over
354            # an empty scene spot.
355            log.info("%r is no longer the target.", self.current_target_item)
356            self.remove_tmp_anchor()
357            self.current_target_item = None
358
359        if item is not None and item is not self.from_item:
360            # The mouse is over an node item (different from the starting node)
361            if self.current_target_item is item:
362                # Avoid reseting the points
363                pass
364            elif self.can_connect(item):
365                # Grab a new anchor
366                log.info("%r is the new target.", item)
367                self.create_tmp_anchor(item)
368                self.set_link_target_anchor(self.tmp_anchor_point)
369                self.current_target_item = item
370            else:
371                log.info("%r does not have compatible channels", item)
372                self.set_link_target_anchor(self.cursor_anchor_point)
373                # TODO: How to indicate that the connection is not possible?
374                #       The node's anchor could be drawn with a 'disabled'
375                #       palette
376        else:
377            self.set_link_target_anchor(self.cursor_anchor_point)
378
379        self.cursor_anchor_point.setPos(event.scenePos())
380
381        return True
382
383    def mouseReleaseEvent(self, event):
384        if self.tmp_link_item:
385            item = self.target_node_item_at(event.scenePos())
386            node = None
387            stack = self.document.undoStack()
388            stack.beginMacro("Add link")
389
390            if item:
391                # If the release was over a widget item
392                # then connect them
393                node = self.scene.node_for_item(item)
394            else:
395                # Release on an empty canvas part
396                # Show a quick menu popup for a new widget creation.
397                try:
398                    node = self.create_new(event)
399                except Exception:
400                    log.error("Failed to create a new node, ending.",
401                              exc_info=True)
402                    node = None
403
404                if node is not None:
405                    self.document.addNode(node)
406
407            if node is not None:
408                self.connect_existing(node)
409            else:
410                self.end()
411
412            stack.endMacro()
413        else:
414            self.end()
415            return False
416
417    def create_new(self, event):
418        """
419        Create and return a new node with a `QuickMenu`.
420        """
421        pos = event.screenPos()
422        menu = self.document.quickMenu()
423        node = self.scene.node_for_item(self.from_item)
424        from_desc = node.description
425
426        def is_compatible(source, sink):
427            return any(scheme.compatible_channels(output, input) \
428                       for output in source.outputs \
429                       for input in sink.inputs)
430
431        if self.direction == self.FROM_SINK:
432            # Reverse the argument order.
433            is_compatible = reversed_arguments(is_compatible)
434
435        def filter(index):
436            desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
437            if desc.isValid():
438                desc = desc.toPyObject()
439                return is_compatible(from_desc, desc)
440            else:
441                return False
442
443        menu.setFilterFunc(filter)
444        try:
445            action = menu.exec_(pos)
446        finally:
447            menu.setFilterFunc(None)
448
449        if action:
450            item = action.property("item").toPyObject()
451            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
452            pos = event.scenePos()
453            # a new widget should be placed so that the connection
454            # stays as it was
455            offset = 31 * (-1 if self.direction == self.FROM_SINK else
456                           1 if self.direction == self.FROM_SOURCE else 0)
457            node = self.document.newNodeHelper(desc,
458                                               position=(pos.x() + offset,
459                                                         pos.y()))
460            return node
461
462    def connect_existing(self, node):
463        """
464        Connect anchor_item to `node`.
465        """
466        if self.direction == self.FROM_SOURCE:
467            source_item = self.source_item
468            source_node = self.scene.node_for_item(source_item)
469            sink_node = node
470        else:
471            source_node = node
472            sink_item = self.sink_item
473            sink_node = self.scene.node_for_item(sink_item)
474
475        try:
476            possible = self.scheme.propose_links(source_node, sink_node)
477
478            log.debug("proposed (weighted) links: %r",
479                      [(s1.name, s2.name, w) for s1, s2, w in possible])
480
481            if not possible:
482                raise NoPossibleLinksError
483
484            source, sink, w = possible[0]
485            links_to_add = [(source, sink)]
486
487            show_link_dialog = False
488
489            # Ambiguous new link request.
490            if len(possible) >= 2:
491                # Check for possible ties in the proposed link weights
492                _, _, w2 = possible[1]
493                if w == w2:
494                    show_link_dialog = True
495
496                # Check for destructive action (i.e. would the new link
497                # replace a previous link)
498                if sink.single and self.scheme.find_links(sink_node=sink_node,
499                                                          sink_channel=sink):
500                    show_link_dialog = True
501
502                if show_link_dialog:
503                    existing = self.scheme.find_links(source_node=source_node,
504                                                      sink_node=sink_node)
505
506                    if existing:
507                        # EditLinksDialog will populate the view with
508                        # existing links
509                        initial_links = None
510                    else:
511                        initial_links = [(source, sink)]
512
513                    links_action = EditNodeLinksAction(
514                                    self.document, source_node, sink_node)
515                    try:
516                        links_action.edit_links(initial_links)
517                    except Exception:
518                        log.error("'EditNodeLinksAction' failed",
519                                  exc_info=True)
520                        raise
521                    # EditNodeLinksAction already commits the links on accepted
522                    links_to_add = []
523
524            for source, sink in links_to_add:
525                if sink.single:
526                    # Remove an existing link to the sink channel if present.
527                    existing_link = self.scheme.find_links(
528                        sink_node=sink_node, sink_channel=sink
529                    )
530
531                    if existing_link:
532                        self.document.removeLink(existing_link[0])
533
534                # Check if the new link is a duplicate of an existing link
535                duplicate = self.scheme.find_links(
536                    source_node, source, sink_node, sink
537                )
538
539                if duplicate:
540                    # Do nothing.
541                    continue
542
543                # Remove temp items before creating a new link
544                self.cleanup()
545
546                link = scheme.SchemeLink(source_node, source, sink_node, sink)
547                self.document.addLink(link)
548
549        except scheme.IncompatibleChannelTypeError:
550            log.info("Cannot connect: invalid channel types.")
551            self.cancel()
552        except scheme.SchemeTopologyError:
553            log.info("Cannot connect: connection creates a cycle.")
554            self.cancel()
555        except NoPossibleLinksError:
556            log.info("Cannot connect: no possible links.")
557            self.cancel()
558        except Exception:
559            log.error("An error occurred during the creation of a new link.",
560                      exc_info=True)
561            self.cancel()
562
563        if not self.isFinished():
564            self.end()
565
566    def end(self):
567        self.cleanup()
568        # Remove the help tip set in mousePressEvent
569        helpevent = QuickHelpTipEvent("", "")
570        QCoreApplication.postEvent(self.document, helpevent)
571        UserInteraction.end(self)
572
573    def cancel(self, reason=UserInteraction.OtherReason):
574        self.cleanup()
575        UserInteraction.cancel(self, reason)
576
577    def cleanup(self):
578        """
579        Cleanup all temporary items in the scene that are left.
580        """
581        if self.tmp_link_item:
582            self.tmp_link_item.setSinkItem(None)
583            self.tmp_link_item.setSourceItem(None)
584
585            if self.tmp_link_item.scene():
586                self.scene.removeItem(self.tmp_link_item)
587
588            self.tmp_link_item = None
589
590        if self.current_target_item:
591            self.remove_tmp_anchor()
592            self.current_target_item = None
593
594        if self.cursor_anchor_point and self.cursor_anchor_point.scene():
595            self.scene.removeItem(self.cursor_anchor_point)
596            self.cursor_anchor_point = None
597
598
599class NewNodeAction(UserInteraction):
600    """
601    Present the user with a quick menu for node selection and
602    create the selected node.
603
604    """
605
606    def mousePressEvent(self, event):
607        if event.button() == Qt.RightButton:
608            self.create_new(event.screenPos())
609            self.end()
610
611    def create_new(self, pos):
612        """
613        Create a new widget with a `QuickMenu` at `pos` (in screen
614        coordinates).
615
616        """
617        menu = self.document.quickMenu()
618        menu.setFilterFunc(None)
619
620        action = menu.exec_(pos)
621        if action:
622            item = action.property("item").toPyObject()
623            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
624            # Get the scene position
625            view = self.document.view()
626            pos = view.mapToScene(view.mapFromGlobal(pos))
627
628            node = self.document.newNodeHelper(desc,
629                                               position=(pos.x(), pos.y()))
630            self.document.addNode(node)
631            return node
632
633
634class RectangleSelectionAction(UserInteraction):
635    """
636    Select items in the scene using a Rectangle selection
637    """
638    def __init__(self, document, *args, **kwargs):
639        UserInteraction.__init__(self, document, *args, **kwargs)
640        # The initial selection at drag start
641        self.initial_selection = None
642        # Selection when last updated in a mouseMoveEvent
643        self.last_selection = None
644        # A selection rect (`QRectF`)
645        self.selection_rect = None
646        # Keyboard modifiers
647        self.modifiers = 0
648
649    def mousePressEvent(self, event):
650        pos = event.scenePos()
651        any_item = self.scene.item_at(pos)
652        if not any_item and event.button() & Qt.LeftButton:
653            self.modifiers = event.modifiers()
654            self.selection_rect = QRectF(pos, QSizeF(0, 0))
655            self.rect_item = QGraphicsRectItem(
656                self.selection_rect.normalized()
657            )
658
659            self.rect_item.setPen(
660                QPen(QBrush(QColor(51, 153, 255, 192)),
661                     0.4, Qt.SolidLine, Qt.RoundCap)
662            )
663
664            self.rect_item.setBrush(
665                QBrush(QColor(168, 202, 236, 192))
666            )
667
668            self.rect_item.setZValue(-100)
669
670            # Clear the focus if necessary.
671            if not self.scene.stickyFocus():
672                self.scene.clearFocus()
673
674            if not self.modifiers & Qt.ControlModifier:
675                self.scene.clearSelection()
676
677            event.accept()
678            return True
679        else:
680            self.cancel(self.ErrorReason)
681            return False
682
683    def mouseMoveEvent(self, event):
684        if not self.rect_item.scene():
685            # Add the rect item to the scene when the mouse moves.
686            self.scene.addItem(self.rect_item)
687        self.update_selection(event)
688        return True
689
690    def mouseReleaseEvent(self, event):
691        if event.button() == Qt.LeftButton:
692            if self.initial_selection is None:
693                # A single click.
694                self.scene.clearSelection()
695            else:
696                self.update_selection(event)
697        self.end()
698        return True
699
700    def update_selection(self, event):
701        """
702        Update the selection rectangle from a QGraphicsSceneMouseEvent
703        `event` instance.
704
705        """
706        if self.initial_selection is None:
707            self.initial_selection = set(self.scene.selectedItems())
708            self.last_selection = self.initial_selection
709
710        pos = event.scenePos()
711        self.selection_rect = QRectF(self.selection_rect.topLeft(), pos)
712
713        # Make sure the rect_item does not cause the scene rect to grow.
714        rect = self._bound_selection_rect(self.selection_rect.normalized())
715
716        # Need that 0.5 constant otherwise the sceneRect will still
717        # grow (anti-aliasing correction by QGraphicsScene?)
718        pw = self.rect_item.pen().width() + 0.5
719
720        self.rect_item.setRect(rect.adjusted(pw, pw, -pw, -pw))
721
722        selected = self.scene.items(self.selection_rect.normalized(),
723                                    Qt.IntersectsItemShape,
724                                    Qt.AscendingOrder)
725
726        selected = set([item for item in selected if \
727                        item.flags() & Qt.ItemIsSelectable])
728
729        if self.modifiers & Qt.ControlModifier:
730            for item in selected | self.last_selection | \
731                    self.initial_selection:
732                item.setSelected(
733                    (item in selected) ^ (item in self.initial_selection)
734                )
735        else:
736            for item in selected.union(self.last_selection):
737                item.setSelected(item in selected)
738
739        self.last_selection = set(self.scene.selectedItems())
740
741    def end(self):
742        self.initial_selection = None
743        self.last_selection = None
744        self.modifiers = 0
745
746        self.rect_item.hide()
747        if self.rect_item.scene() is not None:
748            self.scene.removeItem(self.rect_item)
749        UserInteraction.end(self)
750
751    def viewport_rect(self):
752        """
753        Return the bounding rect of the document's viewport on the scene.
754        """
755        view = self.document.view()
756        vsize = view.viewport().size()
757        viewportrect = QRect(0, 0, vsize.width(), vsize.height())
758        return view.mapToScene(viewportrect).boundingRect()
759
760    def _bound_selection_rect(self, rect):
761        """
762        Bound the selection `rect` to a sensible size.
763        """
764        srect = self.scene.sceneRect()
765        vrect = self.viewport_rect()
766        maxrect = srect.united(vrect)
767        return rect.intersected(maxrect)
768
769
770class EditNodeLinksAction(UserInteraction):
771    """
772    Edit multiple links between two NodeItems using a :class:`EditLinksDialog`
773
774    Parameters
775    ----------
776    document : :class:`SchemeEditWidget`
777        The editor widget.
778    source_node : :class:`SchemeNode`
779        The source (link start) node for the link editor.
780    sink_node : :class:`SchemeNode`
781        The sink (link end) node for the link editor.
782
783    """
784    def __init__(self, document, source_node, sink_node, *args, **kwargs):
785        UserInteraction.__init__(self, document, *args, **kwargs)
786        self.source_node = source_node
787        self.sink_node = sink_node
788
789    def edit_links(self, initial_links=None):
790        """
791        Show and execute the `EditLinksDialog`.
792        Optional `initial_links` list can provide a list of initial
793        `(source, sink)` channel tuples to show in the view, otherwise
794        the dialog is populated with existing links in the scheme (passing
795        an empty list will disable all initial links).
796
797        """
798        from ..canvas.editlinksdialog import EditLinksDialog
799
800        log.info("Constructing a Link Editor dialog.")
801
802        parent = self.scene.views()[0]
803        dlg = EditLinksDialog(parent)
804
805        links = self.scheme.find_links(source_node=self.source_node,
806                                       sink_node=self.sink_node)
807        existing_links = [(link.source_channel, link.sink_channel)
808                          for link in links]
809
810        if initial_links is None:
811            initial_links = list(existing_links)
812
813        dlg.setNodes(self.source_node, self.sink_node)
814        dlg.setLinks(initial_links)
815
816        log.info("Executing a Link Editor Dialog.")
817        rval = dlg.exec_()
818
819        if rval == EditLinksDialog.Accepted:
820            links = dlg.links()
821
822            links_to_add = set(links) - set(existing_links)
823            links_to_remove = set(existing_links) - set(links)
824
825            stack = self.document.undoStack()
826            stack.beginMacro("Edit Links")
827
828            # First remove links into a 'Single' sink channel,
829            # but only the ones that do not have self.source_node as
830            # a source (they will be removed later from links_to_remove)
831            for _, sink_channel in links_to_add:
832                if sink_channel.single:
833                    existing = self.scheme.find_links(
834                        sink_node=self.sink_node,
835                        sink_channel=sink_channel
836                    )
837
838                    existing = [link for link in existing
839                                if link.source_node is not self.source_node]
840
841                    if existing:
842                        assert len(existing) == 1
843                        self.document.removeLink(existing[0])
844
845            for source_channel, sink_channel in links_to_remove:
846                links = self.scheme.find_links(source_node=self.source_node,
847                                               source_channel=source_channel,
848                                               sink_node=self.sink_node,
849                                               sink_channel=sink_channel)
850                assert len(links) == 1
851                self.document.removeLink(links[0])
852
853            for source_channel, sink_channel in links_to_add:
854                link = scheme.SchemeLink(self.source_node, source_channel,
855                                         self.sink_node, sink_channel)
856
857                self.document.addLink(link)
858
859            stack.endMacro()
860
861
862def point_to_tuple(point):
863    """
864    Convert a QPointF into a (x, y) tuple.
865    """
866    return (point.x(), point.y())
867
868
869class NewArrowAnnotation(UserInteraction):
870    """
871    Create a new arrow annotation handler.
872    """
873    def __init__(self, document, *args, **kwargs):
874        UserInteraction.__init__(self, document, *args, **kwargs)
875        self.down_pos = None
876        self.arrow_item = None
877        self.annotation = None
878        self.color = "red"
879
880    def start(self):
881        self.document.view().setCursor(Qt.CrossCursor)
882
883        helpevent = QuickHelpTipEvent(
884            self.tr("Click and drag to create a new arrow"),
885            self.tr('<h3>New arrow annotation</h3>'
886                    '<p>Click and drag to create a new arrow annotation</p>'
887#                    '<a href="help://orange-canvas/arrow-annotations>'
888#                    'More ...</a>'
889                    )
890        )
891        QCoreApplication.postEvent(self.document, helpevent)
892
893        UserInteraction.start(self)
894
895    def setColor(self, color):
896        """
897        Set the color for the new arrow.
898        """
899        self.color = color
900
901    def mousePressEvent(self, event):
902        if event.button() == Qt.LeftButton:
903            self.down_pos = event.scenePos()
904            event.accept()
905            return True
906
907    def mouseMoveEvent(self, event):
908        if event.buttons() & Qt.LeftButton:
909            if self.arrow_item is None and \
910                    (self.down_pos - event.scenePos()).manhattanLength() > \
911                    QApplication.instance().startDragDistance():
912
913                annot = scheme.SchemeArrowAnnotation(
914                    point_to_tuple(self.down_pos),
915                    point_to_tuple(event.scenePos())
916                )
917                annot.set_color(self.color)
918                item = self.scene.add_annotation(annot)
919
920                self.arrow_item = item
921                self.annotation = annot
922
923            if self.arrow_item is not None:
924                p1, p2 = map(self.arrow_item.mapFromScene,
925                             (self.down_pos, event.scenePos()))
926                self.arrow_item.setLine(QLineF(p1, p2))
927
928            event.accept()
929            return True
930
931    def mouseReleaseEvent(self, event):
932        if event.button() == Qt.LeftButton:
933            if self.arrow_item is not None:
934                p1, p2 = self.down_pos, event.scenePos()
935
936                # Commit the annotation to the scheme
937                self.annotation.set_line(point_to_tuple(p1),
938                                         point_to_tuple(p2))
939
940                self.document.addAnnotation(self.annotation)
941
942                p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2))
943                self.arrow_item.setLine(QLineF(p1, p2))
944
945            self.end()
946            return True
947
948    def end(self):
949        self.down_pos = None
950        self.arrow_item = None
951        self.annotation = None
952        self.document.view().setCursor(Qt.ArrowCursor)
953
954        # Clear the help tip
955        helpevent = QuickHelpTipEvent("", "")
956        QCoreApplication.postEvent(self.document, helpevent)
957
958        UserInteraction.end(self)
959
960
961def rect_to_tuple(rect):
962    """
963    Convert a QRectF into a (x, y, width, height) tuple.
964    """
965    return rect.x(), rect.y(), rect.width(), rect.height()
966
967
968class NewTextAnnotation(UserInteraction):
969    """
970    A New Text Annotation interaction handler
971    """
972    def __init__(self, document, *args, **kwargs):
973        UserInteraction.__init__(self, document, *args, **kwargs)
974        self.down_pos = None
975        self.annotation_item = None
976        self.annotation = None
977        self.control = None
978        self.font = document.font()
979
980    def setFont(self, font):
981        self.font = font
982
983    def start(self):
984        self.document.view().setCursor(Qt.CrossCursor)
985
986        helpevent = QuickHelpTipEvent(
987            self.tr("Click to create a new text annotation"),
988            self.tr('<h3>New text annotation</h3>'
989                    '<p>Click (and drag to resize) on the canvas to create '
990                    'a new text annotation item.</p>'
991#                    '<a href="help://orange-canvas/text-annotations">'
992#                    'More ...</a>'
993                    )
994        )
995        QCoreApplication.postEvent(self.document, helpevent)
996
997        UserInteraction.start(self)
998
999    def createNewAnnotation(self, rect):
1000        """
1001        Create a new TextAnnotation at with `rect` as the geometry.
1002        """
1003        annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
1004        font = {"family": unicode(self.font.family()),
1005                "size": self.font.pixelSize()}
1006        annot.set_font(font)
1007
1008        item = self.scene.add_annotation(annot)
1009        item.setTextInteractionFlags(Qt.TextEditorInteraction)
1010        item.setFramePen(QPen(Qt.DashLine))
1011
1012        self.annotation_item = item
1013        self.annotation = annot
1014        self.control = controlpoints.ControlPointRect()
1015        self.control.rectChanged.connect(
1016            self.annotation_item.setGeometry
1017        )
1018        self.scene.addItem(self.control)
1019
1020    def mousePressEvent(self, event):
1021        if event.button() == Qt.LeftButton:
1022            self.down_pos = event.scenePos()
1023            return True
1024
1025    def mouseMoveEvent(self, event):
1026        if event.buttons() & Qt.LeftButton:
1027            if self.annotation_item is None and \
1028                    (self.down_pos - event.scenePos()).manhattanLength() > \
1029                    QApplication.instance().startDragDistance():
1030                rect = QRectF(self.down_pos, event.scenePos()).normalized()
1031                self.createNewAnnotation(rect)
1032
1033            if self.annotation_item is not None:
1034                rect = QRectF(self.down_pos, event.scenePos()).normalized()
1035                self.control.setRect(rect)
1036
1037            return True
1038
1039    def mouseReleaseEvent(self, event):
1040        if event.button() == Qt.LeftButton:
1041            if self.annotation_item is None:
1042                self.createNewAnnotation(QRectF(event.scenePos(),
1043                                                event.scenePos()))
1044                rect = self.defaultTextGeometry(event.scenePos())
1045
1046            else:
1047                rect = QRectF(self.down_pos, event.scenePos()).normalized()
1048
1049            # Commit the annotation to the scheme.
1050            self.annotation.rect = rect_to_tuple(rect)
1051
1052            self.document.addAnnotation(self.annotation)
1053
1054            self.annotation_item.setGeometry(rect)
1055
1056            self.control.rectChanged.disconnect(
1057                self.annotation_item.setGeometry
1058            )
1059            self.control.hide()
1060
1061            # Move the focus to the editor.
1062            self.annotation_item.setFramePen(QPen(Qt.NoPen))
1063            self.annotation_item.setFocus(Qt.OtherFocusReason)
1064            self.annotation_item.startEdit()
1065
1066            self.end()
1067
1068    def defaultTextGeometry(self, point):
1069        """
1070        Return the default text geometry. Used in case the user single
1071        clicked in the scene.
1072
1073        """
1074        font = self.annotation_item.font()
1075        metrics = QFontMetrics(font)
1076        spacing = metrics.lineSpacing()
1077        margin = self.annotation_item.document().documentMargin()
1078
1079        rect = QRectF(QPointF(point.x(), point.y() - spacing - margin),
1080                      QSizeF(150, spacing + 2 * margin))
1081        return rect
1082
1083    def end(self):
1084        if self.control is not None:
1085            self.scene.removeItem(self.control)
1086
1087        self.control = None
1088        self.down_pos = None
1089        self.annotation_item = None
1090        self.annotation = None
1091        self.document.view().setCursor(Qt.ArrowCursor)
1092
1093        # Clear the help tip
1094        helpevent = QuickHelpTipEvent("", "")
1095        QCoreApplication.postEvent(self.document, helpevent)
1096
1097        UserInteraction.end(self)
1098
1099
1100class ResizeTextAnnotation(UserInteraction):
1101    """
1102    Resize a Text Annotation interaction handler.
1103    """
1104    def __init__(self, document, *args, **kwargs):
1105        UserInteraction.__init__(self, document, *args, **kwargs)
1106        self.item = None
1107        self.annotation = None
1108        self.control = None
1109        self.savedFramePen = None
1110        self.savedRect = None
1111
1112    def mousePressEvent(self, event):
1113        pos = event.scenePos()
1114        if self.item is None:
1115            item = self.scene.item_at(pos, items.TextAnnotation)
1116            if item is not None and not item.hasFocus():
1117                self.editItem(item)
1118                return False
1119
1120        return UserInteraction.mousePressEvent(self, event)
1121
1122    def editItem(self, item):
1123        annotation = self.scene.annotation_for_item(item)
1124        rect = item.geometry()  # TODO: map to scene if item has a parent.
1125        control = controlpoints.ControlPointRect(rect=rect)
1126        self.scene.addItem(control)
1127
1128        self.savedFramePen = item.framePen()
1129        self.savedRect = rect
1130
1131        control.rectEdited.connect(item.setGeometry)
1132        control.setFocusProxy(item)
1133
1134        item.setFramePen(QPen(Qt.DashDotLine))
1135        item.geometryChanged.connect(self.__on_textGeometryChanged)
1136
1137        self.item = item
1138
1139        self.annotation = annotation
1140        self.control = control
1141
1142    def commit(self):
1143        """
1144        Commit the current item geometry state to the document.
1145        """
1146        rect = self.item.geometry()
1147        if self.savedRect != rect:
1148            command = commands.SetAttrCommand(
1149                self.annotation, "rect",
1150                (rect.x(), rect.y(), rect.width(), rect.height()),
1151                name="Edit text geometry"
1152            )
1153            self.document.undoStack().push(command)
1154            self.savedRect = rect
1155
1156    def __on_editingFinished(self):
1157        self.commit()
1158        self.end()
1159
1160    def __on_rectEdited(self, rect):
1161        self.item.setGeometry(rect)
1162
1163    def __on_textGeometryChanged(self):
1164        if not self.control.isControlActive():
1165            rect = self.item.geometry()
1166            self.control.setRect(rect)
1167
1168    def cancel(self, reason=UserInteraction.OtherReason):
1169        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1170        if self.item is not None and self.savedRect is not None:
1171            self.item.setGeometry(self.savedRect)
1172
1173        UserInteraction.cancel(self, reason)
1174
1175    def end(self):
1176        if self.control is not None:
1177            self.scene.removeItem(self.control)
1178
1179        if self.item is not None:
1180            self.item.setFramePen(self.savedFramePen)
1181
1182        self.item = None
1183        self.annotation = None
1184        self.control = None
1185
1186        UserInteraction.end(self)
1187
1188
1189class ResizeArrowAnnotation(UserInteraction):
1190    """
1191    Resize an Arrow Annotation interaction handler.
1192    """
1193    def __init__(self, document, *args, **kwargs):
1194        UserInteraction.__init__(self, document, *args, **kwargs)
1195        self.item = None
1196        self.annotation = None
1197        self.control = None
1198        self.savedLine = None
1199
1200    def mousePressEvent(self, event):
1201        pos = event.scenePos()
1202        if self.item is None:
1203            item = self.scene.item_at(pos, items.ArrowAnnotation)
1204            if item is not None and not item.hasFocus():
1205                self.editItem(item)
1206                return False
1207
1208        return UserInteraction.mousePressEvent(self, event)
1209
1210    def editItem(self, item):
1211        annotation = self.scene.annotation_for_item(item)
1212        control = controlpoints.ControlPointLine()
1213        self.scene.addItem(control)
1214
1215        line = item.line()
1216        self.savedLine = line
1217
1218        p1, p2 = map(item.mapToScene, (line.p1(), line.p2()))
1219
1220        control.setLine(QLineF(p1, p2))
1221        control.setFocusProxy(item)
1222        control.lineEdited.connect(self.__on_lineEdited)
1223
1224        item.geometryChanged.connect(self.__on_lineGeometryChanged)
1225
1226        self.item = item
1227        self.annotation = annotation
1228        self.control = control
1229
1230    def commit(self):
1231        """Commit the current geometry of the item to the document.
1232
1233        .. note:: Does nothing if the actual geometry is not changed.
1234
1235        """
1236        line = self.control.line()
1237        p1, p2 = line.p1(), line.p2()
1238
1239        if self.item.line() != self.savedLine:
1240            command = commands.SetAttrCommand(
1241                self.annotation,
1242                "geometry",
1243                ((p1.x(), p1.y()), (p2.x(), p2.y())),
1244                name="Edit arrow geometry",
1245            )
1246            self.document.undoStack().push(command)
1247            self.savedLine = self.item.line()
1248
1249    def __on_editingFinished(self):
1250        self.commit()
1251        self.end()
1252
1253    def __on_lineEdited(self, line):
1254        p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2()))
1255        self.item.setLine(QLineF(p1, p2))
1256
1257    def __on_lineGeometryChanged(self):
1258        # Possible geometry change from out of our control, for instance
1259        # item move as a part of a selection group.
1260        if not self.control.isControlActive():
1261            line = self.item.line()
1262            p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2()))
1263            self.control.setLine(QLineF(p1, p2))
1264
1265    def cancel(self, reason=UserInteraction.OtherReason):
1266        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1267        if self.item is not None and self.savedLine is not None:
1268            self.item.setLine(self.savedLine)
1269
1270        UserInteraction.cancel(self, reason)
1271
1272    def end(self):
1273        if self.control is not None:
1274            self.scene.removeItem(self.control)
1275
1276        if self.item is not None:
1277            self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged)
1278
1279        self.control = None
1280        self.item = None
1281        self.annotation = None
1282
1283        UserInteraction.end(self)
Note: See TracBrowser for help on using the repository browser.