source: orange/Orange/OrangeCanvas/document/interactions.py @ 11447:f2279307183c

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

Moved 'EditLinksWidget' from 'canvas' to 'document' subpackage.

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