source: orange/Orange/OrangeCanvas/document/interactions.py @ 11449:d6304e011fb4

Revision 11449:d6304e011fb4, 46.3 KB checked in by Ales Erjavec <ales.erjavec@…>, 12 months ago (diff)

Refactored link edit interactions code into smaller functions.

RevLine 
[11113]1"""
[11443]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
[11113]13
14"""
[11443]15
[11113]16import logging
17
[11151]18from PyQt4.QtGui import (
[11193]19    QApplication, QGraphicsRectItem, QPen, QBrush, QColor, QFontMetrics
[11151]20)
21
[11272]22from PyQt4.QtCore import (
23    Qt, QObject, QCoreApplication, QSizeF, QPointF, QRect, QRectF, QLineF
24)
25
[11195]26from PyQt4.QtCore import pyqtSignal as Signal
[11113]27
28from ..registry.qt import QtWidgetRegistry
29from .. import scheme
[11151]30from ..canvas import items
[11162]31from ..canvas.items import controlpoints
[11272]32from ..gui.quickhelp import QuickHelpTipEvent
[11162]33from . import commands
[11447]34from .editlinksdialog import EditLinksDialog
[11113]35
36log = logging.getLogger(__name__)
37
38
[11195]39class UserInteraction(QObject):
[11443]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
[11195]65    OtherReason = 5
66
[11443]67    #: Emitted when the interaction is set on the scene.
[11195]68    started = Signal()
69
[11443]70    #: Emitted when the interaction finishes successfully.
[11195]71    finished = Signal()
72
[11443]73    #: Emitted when the interaction ends (canceled or finished)
[11195]74    ended = Signal()
75
[11443]76    #: Emitted when the interaction is canceled.
[11195]77    canceled = Signal([], [int])
78
79    def __init__(self, document, parent=None, deleteOnEnd=True):
80        QObject.__init__(self, parent)
[11151]81        self.document = document
82        self.scene = document.scene()
83        self.scheme = document.scheme()
[11195]84        self.deleteOnEnd = deleteOnEnd
85
86        self.cancelOnEsc = False
87
88        self.__finished = False
89        self.__canceled = False
90        self.__cancelReason = self.NoReason
[11113]91
92    def start(self):
[11443]93        """
94        Start the interaction. This is called by the :class:`CanvasScene` when
[11195]95        the interaction is installed.
96
[11443]97        .. note:: Must be called from subclass implementations.
[11195]98
99        """
100        self.started.emit()
[11113]101
102    def end(self):
[11443]103        """
104        Finish the interaction. Restore any leftover state in this method.
[11195]105
[11443]106        .. note:: This gets called from the default :func:`cancel`
107                  implementation.
[11195]108
109        """
110        self.__finished = True
111
[11113]112        if self.scene.user_interaction_handler is self:
113            self.scene.set_user_interaction_handler(None)
114
[11195]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):
[11443]127        """
128        Cancel the interaction with `reason`.
[11195]129        """
130
131        self.__canceled = True
132        self.__cancelReason = reason
133
[11151]134        self.end()
[11113]135
[11195]136    def isFinished(self):
[11443]137        """
138        Is the interaction finished.
[11195]139        """
140        return self.__finished
141
142    def isCanceled(self):
[11443]143        """
144        Was the interaction canceled.
[11195]145        """
146        return self.__canceled
147
148    def cancelReason(self):
[11443]149        """
150        Return the reason the interaction was canceled.
[11195]151        """
152        return self.__cancelReason
153
[11151]154    def mousePressEvent(self, event):
[11443]155        """
156        Handle a `QGraphicsScene.mousePressEvent`.
157        """
[11113]158        return False
159
[11151]160    def mouseMoveEvent(self, event):
[11443]161        """
162        Handle a `GraphicsScene.mouseMoveEvent`.
163        """
[11113]164        return False
165
[11151]166    def mouseReleaseEvent(self, event):
[11443]167        """
168        Handle a `QGraphicsScene.mouseReleaseEvent`.
169        """
[11113]170        return False
171
[11151]172    def mouseDoubleClickEvent(self, event):
[11443]173        """
174        Handle a `QGraphicsScene.mouseDoubleClickEvent`.
175        """
[11151]176        return False
177
178    def keyPressEvent(self, event):
[11443]179        """
180        Handle a `QGraphicsScene.keyPressEvent`
181        """
[11195]182        if self.cancelOnEsc and event.key() == Qt.Key_Escape:
183            self.cancel(self.UserCancelReason)
[11151]184        return False
185
186    def keyReleaseEvent(self, event):
[11443]187        """
188        Handle a `QGraphicsScene.keyPressEvent`
189        """
[11113]190        return False
191
192
193class NoPossibleLinksError(ValueError):
194    pass
195
196
[11164]197def reversed_arguments(func):
[11443]198    """
199    Return a function with reversed argument order.
[11164]200    """
201    def wrapped(*args):
202        return func(*reversed(args))
203    return wrapped
204
205
[11113]206class NewLinkAction(UserInteraction):
[11443]207    """
208    User drags a new link from an existing `NodeAnchorItem` to create
[11113]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
[11195]218    def __init__(self, document, *args, **kwargs):
219        UserInteraction.__init__(self, document, *args, **kwargs)
[11113]220        self.source_item = None
221        self.sink_item = None
[11151]222        self.from_item = None
223        self.direction = None
[11113]224
[11443]225        # An `NodeItem` currently under the mouse as a possible
226        # link drop target.
[11113]227        self.current_target_item = None
[11443]228        # A temporary `LinkItem` used while dragging.
[11113]229        self.tmp_link_item = None
[11443]230        # An temporary `AnchorPoint` inserted into `current_target_item`
[11113]231        self.tmp_anchor_point = None
[11443]232        # An `AnchorPoint` following the mouse cursor
[11113]233        self.cursor_anchor_point = None
234
235    def remove_tmp_anchor(self):
[11443]236        """
237        Remove a temporary anchor point from the current target item.
[11113]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):
[11443]246        """
247        Create a new tmp anchor at the `item` (:class:`NodeItem`).
[11113]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):
[11443]256        """
257        Is the connection between `self.from_item` (item where the drag
258        started) and `target_item` possible.
[11113]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):
[11443]270        """
271        Set the temp line target anchor.
[11113]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
[11153]278    def target_node_item_at(self, pos):
279        """
[11443]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.
[11153]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
[11151]297    def mousePressEvent(self, event):
298        anchor_item = self.scene.item_at(event.scenePos(),
[11241]299                                         items.NodeAnchorItem,
300                                         buttons=Qt.LeftButton)
[11151]301        if anchor_item and event.button() == Qt.LeftButton:
302            # Start a new link starting at item
[11153]303            self.from_item = anchor_item.parentNodeItem()
[11151]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()
[11272]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
[11151]324            return True
325        else:
[11195]326            # Whoever put us in charge did not know what he was doing.
327            self.cancel(self.ErrorReason)
[11151]328            return False
329
330    def mouseMoveEvent(self, event):
[11113]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
[11153]349        item = self.target_node_item_at(event.scenePos())
[11113]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
[11151]384    def mouseReleaseEvent(self, event):
[11113]385        if self.tmp_link_item:
[11153]386            item = self.target_node_item_at(event.scenePos())
[11113]387            node = None
[11151]388            stack = self.document.undoStack()
389            stack.beginMacro("Add link")
390
[11113]391            if item:
392                # If the release was over a widget item
393                # then connect them
[11151]394                node = self.scene.node_for_item(item)
[11113]395            else:
396                # Release on an empty canvas part
397                # Show a quick menu popup for a new widget creation.
398                try:
[11151]399                    node = self.create_new(event)
[11164]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:
[11151]406                    self.document.addNode(node)
[11113]407
408            if node is not None:
[11449]409                if self.direction == self.FROM_SOURCE:
410                    source_node = self.scene.node_for_item(self.source_item)
411                    sink_node = node
412                else:
413                    source_node = node
414                    sink_node = self.scene.node_for_item(self.sink_item)
415                self.connect_nodes(source_node, sink_node)
[11113]416            else:
417                self.end()
[11151]418
419            stack.endMacro()
[11113]420        else:
421            self.end()
422            return False
423
[11151]424    def create_new(self, event):
[11443]425        """
426        Create and return a new node with a `QuickMenu`.
[11151]427        """
428        pos = event.screenPos()
[11164]429        menu = self.document.quickMenu()
430        node = self.scene.node_for_item(self.from_item)
431        from_desc = node.description
[11151]432
[11164]433        def is_compatible(source, sink):
434            return any(scheme.compatible_channels(output, input) \
435                       for output in source.outputs \
436                       for input in sink.inputs)
437
438        if self.direction == self.FROM_SINK:
439            # Reverse the argument order.
440            is_compatible = reversed_arguments(is_compatible)
441
442        def filter(index):
443            desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
444            if desc.isValid():
445                desc = desc.toPyObject()
446                return is_compatible(from_desc, desc)
447            else:
448                return False
449
450        menu.setFilterFunc(filter)
451        try:
452            action = menu.exec_(pos)
453        finally:
454            menu.setFilterFunc(None)
[11151]455
456        if action:
457            item = action.property("item").toPyObject()
458            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
459            pos = event.scenePos()
[11351]460            # a new widget should be placed so that the connection
461            # stays as it was
462            offset = 31 * (-1 if self.direction == self.FROM_SINK else
463                           1 if self.direction == self.FROM_SOURCE else 0)
[11279]464            node = self.document.newNodeHelper(desc,
[11351]465                                               position=(pos.x() + offset,
466                                                         pos.y()))
[11151]467            return node
468
[11449]469    def connect_nodes(self, source_node, sink_node):
[11443]470        """
[11449]471        Connect `source_node` to `sink_node`. If there are more then one
472        equally weighted and non conflicting links possible present a
473        detailed dialog for link editing.
474
[11113]475        """
476        try:
477            possible = self.scheme.propose_links(source_node, sink_node)
478
[11199]479            log.debug("proposed (weighted) links: %r",
480                      [(s1.name, s2.name, w) for s1, s2, w in possible])
481
[11113]482            if not possible:
483                raise NoPossibleLinksError
484
[11199]485            source, sink, w = possible[0]
[11449]486
487            # just a list of signal tuples for now, will be converted
488            # to SchemeLinks later
[11199]489            links_to_add = [(source, sink)]
[11449]490            links_to_remove = []
[11199]491            show_link_dialog = False
492
493            # Ambiguous new link request.
[11113]494            if len(possible) >= 2:
[11199]495                # Check for possible ties in the proposed link weights
[11113]496                _, _, w2 = possible[1]
[11199]497                if w == w2:
498                    show_link_dialog = True
499
500                # Check for destructive action (i.e. would the new link
501                # replace a previous link)
502                if sink.single and self.scheme.find_links(sink_node=sink_node,
503                                                          sink_channel=sink):
504                    show_link_dialog = True
505
[11449]506            if show_link_dialog:
507                existing = self.scheme.find_links(source_node=source_node,
508                                                  sink_node=sink_node)
[11277]509
[11449]510                if existing:
511                    # edit_links will populate the view with existing links
512                    initial_links = None
513                else:
514                    initial_links = [(source, sink)]
[11277]515
[11449]516                try:
517                    _, links_to_add, links_to_remove = self.edit_links(
518                        source_node, sink_node, initial_links
519                    )
520                except Exception:
521                    log.error("Failed to edit the links",
522                              exc_info=True)
523                    raise
524            else:
525                # links_to_add now needs to be a list of actual SchemeLinks
526                links_to_add = [scheme.SchemeLink(
527                                    source_node, source_channel,
528                                    sink_node, sink_channel)
529                                for source_channel, sink_channel
530                                in links_to_add]
[11113]531
[11449]532                links_to_add, links_to_remove = \
533                    add_links_plan(self.scheme, links_to_add)
[11113]534
[11449]535            # Remove temp items before creating any new links
536            self.cleanup()
[11113]537
[11449]538            for link in links_to_remove:
539                self.document.removeLink(link)
540
541            for link in links_to_add:
542                # Check if the new requested link is a duplicate of an
543                # existing link
[11113]544                duplicate = self.scheme.find_links(
[11449]545                    link.source_node, link.source_channel,
546                    link.sink_node, link.sink_channel
[11113]547                )
548
[11449]549                if not duplicate:
550                    self.document.addLink(link)
[11113]551
552        except scheme.IncompatibleChannelTypeError:
553            log.info("Cannot connect: invalid channel types.")
554            self.cancel()
555        except scheme.SchemeTopologyError:
556            log.info("Cannot connect: connection creates a cycle.")
557            self.cancel()
558        except NoPossibleLinksError:
559            log.info("Cannot connect: no possible links.")
560            self.cancel()
561        except Exception:
562            log.error("An error occurred during the creation of a new link.",
563                      exc_info=True)
564            self.cancel()
565
[11195]566        if not self.isFinished():
567            self.end()
[11113]568
[11449]569    def edit_links(self, source_node, sink_node, initial_links=None):
570        """
571        Show and execute the `EditLinksDialog`.
572        Optional `initial_links` list can provide a list of initial
573        `(source, sink)` channel tuples to show in the view, otherwise
574        the dialog is populated with existing links in the scheme (passing
575        an empty list will disable all initial links).
576
577        """
578        status, links_to_add, links_to_remove = \
579            edit_links(
580                self.scheme, source_node, sink_node, initial_links,
581                parent=self.document
582            )
583
584        if status == EditLinksDialog.Accepted:
585            links_to_add = [scheme.SchemeLink(
586                                source_node, source_channel,
587                                sink_node, sink_channel)
588                            for source_channel, sink_channel in links_to_add]
589
590            links_to_remove = [self.scheme.find_links(
591                                   source_node, source_channel,
592                                   sink_node, sink_channel)
593                               for source_channel, sink_channel
594                               in links_to_remove]
595
596            links_to_remove = reduce(list.__add__, links_to_remove, [])
597            conflicting = filter(None,
598                                 [conflicting_single_link(self.scheme, link)
599                                  for link in links_to_add])
600            for link in conflicting:
601                if link not in links_to_remove:
602                    links_to_remove.append(link)
603
604            return status, links_to_add, links_to_remove
605        else:
606            return status, [], []
607
[11113]608    def end(self):
[11132]609        self.cleanup()
[11443]610        # Remove the help tip set in mousePressEvent
[11272]611        helpevent = QuickHelpTipEvent("", "")
612        QCoreApplication.postEvent(self.document, helpevent)
[11113]613        UserInteraction.end(self)
614
[11195]615    def cancel(self, reason=UserInteraction.OtherReason):
616        self.cleanup()
617        UserInteraction.cancel(self, reason)
[11132]618
619    def cleanup(self):
[11443]620        """
621        Cleanup all temporary items in the scene that are left.
[11132]622        """
623        if self.tmp_link_item:
624            self.tmp_link_item.setSinkItem(None)
[11113]625            self.tmp_link_item.setSourceItem(None)
626
[11132]627            if self.tmp_link_item.scene():
628                self.scene.removeItem(self.tmp_link_item)
[11113]629
[11132]630            self.tmp_link_item = None
631
632        if self.current_target_item:
633            self.remove_tmp_anchor()
634            self.current_target_item = None
635
636        if self.cursor_anchor_point and self.cursor_anchor_point.scene():
637            self.scene.removeItem(self.cursor_anchor_point)
638            self.cursor_anchor_point = None
[11113]639
640
[11449]641def edit_links(scheme, source_node, sink_node, initial_links=None,
642               parent=None):
643    """
644    Show and execute the `EditLinksDialog`.
645    Optional `initial_links` list can provide a list of initial
646    `(source, sink)` channel tuples to show in the view, otherwise
647    the dialog is populated with existing links in the scheme (passing
648    an empty list will disable all initial links).
649
650    """
651    log.info("Constructing a Link Editor dialog.")
652
653    dlg = EditLinksDialog(parent)
654
655    # all SchemeLinks between the two nodes.
656    links = scheme.find_links(source_node=source_node, sink_node=sink_node)
657    existing_links = [(link.source_channel, link.sink_channel)
658                      for link in links]
659
660    if initial_links is None:
661        initial_links = list(existing_links)
662
663    dlg.setNodes(source_node, sink_node)
664    dlg.setLinks(initial_links)
665
666    log.info("Executing a Link Editor Dialog.")
667    rval = dlg.exec_()
668
669    if rval == EditLinksDialog.Accepted:
670        edited_links = dlg.links()
671
672        # Differences
673        links_to_add = set(edited_links) - set(existing_links)
674        links_to_remove = set(existing_links) - set(edited_links)
675        return rval, list(links_to_add), list(links_to_remove)
676    else:
677        return rval, [], []
678
679
680def add_links_plan(scheme, links, force_replace=False):
681    """
682    Return a plan for adding a list of links to the scheme.
683    """
684    links_to_add = list(links)
685    links_to_remove = [conflicting_single_link(scheme, link)
686                       for link in links]
687    links_to_remove = filter(None, links_to_remove)
688
689    if not force_replace:
690        links_to_add, links_to_remove = remove_duplicates(links_to_add,
691                                                          links_to_remove)
692    return links_to_add, links_to_remove
693
694
695def conflicting_single_link(scheme, link):
696    """
697    Find and return an existing link in `scheme` connected to the same
698    input channel as `link` if the channel has the 'single' flag.
699    If no such channel exists (or sink channel is not 'single')
700    return `None`.
701
702    """
703
704    if link.sink_channel.single:
705        existing = scheme.find_links(
706            sink_node=link.sink_node,
707            sink_channel=link.sink_channel
708        )
709
710        if existing:
711            assert len(existing) == 1
712            return existing[0]
713    return None
714
715
716def remove_duplicates(links_to_add, links_to_remove):
717    def link_key(link):
718        return (link.source_node, link.source_channel,
719                link.sink_node, link.sink_channel)
720
721    add_keys = map(link_key, links_to_add)
722    remove_keys = map(link_key, links_to_remove)
723    duplicate_keys = set(add_keys).intersection(remove_keys)
724
725    def not_duplicate(link):
726        return link_key(link) not in duplicate_keys
727
728    links_to_add = filter(not_duplicate, links_to_add)
729    links_to_remove = filter(not_duplicate, links_to_remove)
730    return links_to_add, links_to_remove
731
732
[11113]733class NewNodeAction(UserInteraction):
[11443]734    """
735    Present the user with a quick menu for node selection and
[11113]736    create the selected node.
737
738    """
739
[11151]740    def mousePressEvent(self, event):
[11113]741        if event.button() == Qt.RightButton:
[11198]742            self.create_new(event.screenPos())
[11113]743            self.end()
744
[11198]745    def create_new(self, pos):
[11443]746        """
747        Create a new widget with a `QuickMenu` at `pos` (in screen
748        coordinates).
[11198]749
[11113]750        """
[11164]751        menu = self.document.quickMenu()
752        menu.setFilterFunc(None)
[11113]753
[11164]754        action = menu.exec_(pos)
[11113]755        if action:
756            item = action.property("item").toPyObject()
757            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
[11198]758            # Get the scene position
759            view = self.document.view()
760            pos = view.mapToScene(view.mapFromGlobal(pos))
[11279]761
762            node = self.document.newNodeHelper(desc,
763                                               position=(pos.x(), pos.y()))
[11151]764            self.document.addNode(node)
[11113]765            return node
766
767
768class RectangleSelectionAction(UserInteraction):
[11443]769    """
770    Select items in the scene using a Rectangle selection
[11113]771    """
[11195]772    def __init__(self, document, *args, **kwargs):
773        UserInteraction.__init__(self, document, *args, **kwargs)
[11443]774        # The initial selection at drag start
[11113]775        self.initial_selection = None
[11443]776        # Selection when last updated in a mouseMoveEvent
[11240]777        self.last_selection = None
[11443]778        # A selection rect (`QRectF`)
[11198]779        self.selection_rect = None
[11443]780        # Keyboard modifiers
[11240]781        self.modifiers = 0
[11113]782
[11151]783    def mousePressEvent(self, event):
[11113]784        pos = event.scenePos()
[11151]785        any_item = self.scene.item_at(pos)
786        if not any_item and event.button() & Qt.LeftButton:
[11240]787            self.modifiers = event.modifiers()
[11151]788            self.selection_rect = QRectF(pos, QSizeF(0, 0))
789            self.rect_item = QGraphicsRectItem(
790                self.selection_rect.normalized()
791            )
[11113]792
[11151]793            self.rect_item.setPen(
794                QPen(QBrush(QColor(51, 153, 255, 192)),
795                     0.4, Qt.SolidLine, Qt.RoundCap)
796            )
797
798            self.rect_item.setBrush(
799                QBrush(QColor(168, 202, 236, 192))
800            )
801
802            self.rect_item.setZValue(-100)
803
804            # Clear the focus if necessary.
805            if not self.scene.stickyFocus():
806                self.scene.clearFocus()
[11240]807
808            if not self.modifiers & Qt.ControlModifier:
809                self.scene.clearSelection()
810
[11151]811            event.accept()
812            return True
813        else:
[11195]814            self.cancel(self.ErrorReason)
[11151]815            return False
816
817    def mouseMoveEvent(self, event):
818        if not self.rect_item.scene():
[11443]819            # Add the rect item to the scene when the mouse moves.
[11151]820            self.scene.addItem(self.rect_item)
[11113]821        self.update_selection(event)
[11203]822        return True
[11113]823
[11151]824    def mouseReleaseEvent(self, event):
[11240]825        if event.button() == Qt.LeftButton:
826            if self.initial_selection is None:
827                # A single click.
828                self.scene.clearSelection()
829            else:
830                self.update_selection(event)
[11113]831        self.end()
[11203]832        return True
[11113]833
834    def update_selection(self, event):
[11443]835        """
836        Update the selection rectangle from a QGraphicsSceneMouseEvent
837        `event` instance.
838
839        """
[11113]840        if self.initial_selection is None:
[11240]841            self.initial_selection = set(self.scene.selectedItems())
842            self.last_selection = self.initial_selection
[11113]843
844        pos = event.scenePos()
845        self.selection_rect = QRectF(self.selection_rect.topLeft(), pos)
[11237]846
[11443]847        # Make sure the rect_item does not cause the scene rect to grow.
[11237]848        rect = self._bound_selection_rect(self.selection_rect.normalized())
849
[11443]850        # Need that 0.5 constant otherwise the sceneRect will still
851        # grow (anti-aliasing correction by QGraphicsScene?)
[11237]852        pw = self.rect_item.pen().width() + 0.5
853
854        self.rect_item.setRect(rect.adjusted(pw, pw, -pw, -pw))
[11113]855
856        selected = self.scene.items(self.selection_rect.normalized(),
857                                    Qt.IntersectsItemShape,
858                                    Qt.AscendingOrder)
859
[11240]860        selected = set([item for item in selected if \
861                        item.flags() & Qt.ItemIsSelectable])
862
863        if self.modifiers & Qt.ControlModifier:
864            for item in selected | self.last_selection | \
865                    self.initial_selection:
866                item.setSelected(
867                    (item in selected) ^ (item in self.initial_selection)
868                )
[11113]869        else:
[11240]870            for item in selected.union(self.last_selection):
871                item.setSelected(item in selected)
872
873        self.last_selection = set(self.scene.selectedItems())
[11113]874
875    def end(self):
876        self.initial_selection = None
[11240]877        self.last_selection = None
878        self.modifiers = 0
879
[11113]880        self.rect_item.hide()
[11159]881        if self.rect_item.scene() is not None:
882            self.scene.removeItem(self.rect_item)
[11113]883        UserInteraction.end(self)
884
[11237]885    def viewport_rect(self):
[11443]886        """
887        Return the bounding rect of the document's viewport on the scene.
[11237]888        """
889        view = self.document.view()
890        vsize = view.viewport().size()
891        viewportrect = QRect(0, 0, vsize.width(), vsize.height())
892        return view.mapToScene(viewportrect).boundingRect()
893
894    def _bound_selection_rect(self, rect):
[11443]895        """
896        Bound the selection `rect` to a sensible size.
[11237]897        """
898        srect = self.scene.sceneRect()
899        vrect = self.viewport_rect()
900        maxrect = srect.united(vrect)
901        return rect.intersected(maxrect)
902
[11113]903
904class EditNodeLinksAction(UserInteraction):
[11443]905    """
[11449]906    Edit multiple links between two :class:`SchemeNode` instances using
907    a :class:`EditLinksDialog`
[11443]908
909    Parameters
910    ----------
911    document : :class:`SchemeEditWidget`
912        The editor widget.
913    source_node : :class:`SchemeNode`
914        The source (link start) node for the link editor.
915    sink_node : :class:`SchemeNode`
916        The sink (link end) node for the link editor.
917
918    """
[11195]919    def __init__(self, document, source_node, sink_node, *args, **kwargs):
920        UserInteraction.__init__(self, document, *args, **kwargs)
[11113]921        self.source_node = source_node
922        self.sink_node = sink_node
923
[11277]924    def edit_links(self, initial_links=None):
925        """
926        Show and execute the `EditLinksDialog`.
[11443]927        Optional `initial_links` list can provide a list of initial
[11277]928        `(source, sink)` channel tuples to show in the view, otherwise
[11443]929        the dialog is populated with existing links in the scheme (passing
930        an empty list will disable all initial links).
[11277]931
932        """
[11113]933        log.info("Constructing a Link Editor dialog.")
934
[11449]935        dlg = EditLinksDialog(self.document)
[11113]936
937        links = self.scheme.find_links(source_node=self.source_node,
938                                       sink_node=self.sink_node)
939        existing_links = [(link.source_channel, link.sink_channel)
940                          for link in links]
941
[11277]942        if initial_links is None:
943            initial_links = list(existing_links)
944
[11113]945        dlg.setNodes(self.source_node, self.sink_node)
[11277]946        dlg.setLinks(initial_links)
[11113]947
948        log.info("Executing a Link Editor Dialog.")
949        rval = dlg.exec_()
950
951        if rval == EditLinksDialog.Accepted:
952            links = dlg.links()
953
954            links_to_add = set(links) - set(existing_links)
955            links_to_remove = set(existing_links) - set(links)
956
[11151]957            stack = self.document.undoStack()
958            stack.beginMacro("Edit Links")
959
[11443]960            # First remove links into a 'Single' sink channel,
[11276]961            # but only the ones that do not have self.source_node as
962            # a source (they will be removed later from links_to_remove)
963            for _, sink_channel in links_to_add:
964                if sink_channel.single:
965                    existing = self.scheme.find_links(
966                        sink_node=self.sink_node,
967                        sink_channel=sink_channel
968                    )
969
970                    existing = [link for link in existing
971                                if link.source_node is not self.source_node]
972
973                    if existing:
974                        assert len(existing) == 1
975                        self.document.removeLink(existing[0])
976
[11113]977            for source_channel, sink_channel in links_to_remove:
978                links = self.scheme.find_links(source_node=self.source_node,
979                                               source_channel=source_channel,
980                                               sink_node=self.sink_node,
981                                               sink_channel=sink_channel)
[11276]982                assert len(links) == 1
[11151]983                self.document.removeLink(links[0])
[11113]984
985            for source_channel, sink_channel in links_to_add:
986                link = scheme.SchemeLink(self.source_node, source_channel,
987                                         self.sink_node, sink_channel)
[11151]988
989                self.document.addLink(link)
[11276]990
[11151]991            stack.endMacro()
[11113]992
993
994def point_to_tuple(point):
[11443]995    """
996    Convert a QPointF into a (x, y) tuple.
997    """
998    return (point.x(), point.y())
[11113]999
1000
1001class NewArrowAnnotation(UserInteraction):
[11443]1002    """
1003    Create a new arrow annotation handler.
[11113]1004    """
[11195]1005    def __init__(self, document, *args, **kwargs):
1006        UserInteraction.__init__(self, document, *args, **kwargs)
[11113]1007        self.down_pos = None
1008        self.arrow_item = None
1009        self.annotation = None
[11201]1010        self.color = "red"
[11113]1011
[11151]1012    def start(self):
1013        self.document.view().setCursor(Qt.CrossCursor)
[11272]1014
1015        helpevent = QuickHelpTipEvent(
1016            self.tr("Click and drag to create a new arrow"),
1017            self.tr('<h3>New arrow annotation</h3>'
1018                    '<p>Click and drag to create a new arrow annotation</p>'
1019#                    '<a href="help://orange-canvas/arrow-annotations>'
1020#                    'More ...</a>'
1021                    )
1022        )
1023        QCoreApplication.postEvent(self.document, helpevent)
1024
[11151]1025        UserInteraction.start(self)
1026
[11201]1027    def setColor(self, color):
[11443]1028        """
1029        Set the color for the new arrow.
1030        """
[11201]1031        self.color = color
1032
[11151]1033    def mousePressEvent(self, event):
[11113]1034        if event.button() == Qt.LeftButton:
1035            self.down_pos = event.scenePos()
1036            event.accept()
1037            return True
1038
[11151]1039    def mouseMoveEvent(self, event):
[11113]1040        if event.buttons() & Qt.LeftButton:
1041            if self.arrow_item is None and \
1042                    (self.down_pos - event.scenePos()).manhattanLength() > \
1043                    QApplication.instance().startDragDistance():
1044
1045                annot = scheme.SchemeArrowAnnotation(
1046                    point_to_tuple(self.down_pos),
1047                    point_to_tuple(event.scenePos())
1048                )
[11201]1049                annot.set_color(self.color)
[11113]1050                item = self.scene.add_annotation(annot)
[11201]1051
[11113]1052                self.arrow_item = item
1053                self.annotation = annot
1054
1055            if self.arrow_item is not None:
[11162]1056                p1, p2 = map(self.arrow_item.mapFromScene,
1057                             (self.down_pos, event.scenePos()))
1058                self.arrow_item.setLine(QLineF(p1, p2))
1059
[11113]1060            event.accept()
1061            return True
1062
[11151]1063    def mouseReleaseEvent(self, event):
[11113]1064        if event.button() == Qt.LeftButton:
1065            if self.arrow_item is not None:
[11162]1066                p1, p2 = self.down_pos, event.scenePos()
[11113]1067
1068                # Commit the annotation to the scheme
[11162]1069                self.annotation.set_line(point_to_tuple(p1),
1070                                         point_to_tuple(p2))
1071
[11151]1072                self.document.addAnnotation(self.annotation)
[11113]1073
[11162]1074                p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2))
1075                self.arrow_item.setLine(QLineF(p1, p2))
[11113]1076
1077            self.end()
1078            return True
1079
1080    def end(self):
1081        self.down_pos = None
1082        self.arrow_item = None
1083        self.annotation = None
[11151]1084        self.document.view().setCursor(Qt.ArrowCursor)
[11272]1085
1086        # Clear the help tip
1087        helpevent = QuickHelpTipEvent("", "")
1088        QCoreApplication.postEvent(self.document, helpevent)
1089
[11113]1090        UserInteraction.end(self)
1091
1092
1093def rect_to_tuple(rect):
[11443]1094    """
1095    Convert a QRectF into a (x, y, width, height) tuple.
1096    """
[11113]1097    return rect.x(), rect.y(), rect.width(), rect.height()
1098
1099
1100class NewTextAnnotation(UserInteraction):
[11443]1101    """
1102    A New Text Annotation interaction handler
1103    """
[11195]1104    def __init__(self, document, *args, **kwargs):
1105        UserInteraction.__init__(self, document, *args, **kwargs)
[11113]1106        self.down_pos = None
1107        self.annotation_item = None
1108        self.annotation = None
[11162]1109        self.control = None
[11201]1110        self.font = document.font()
1111
1112    def setFont(self, font):
1113        self.font = font
[11113]1114
[11151]1115    def start(self):
1116        self.document.view().setCursor(Qt.CrossCursor)
[11272]1117
1118        helpevent = QuickHelpTipEvent(
1119            self.tr("Click to create a new text annotation"),
1120            self.tr('<h3>New text annotation</h3>'
1121                    '<p>Click (and drag to resize) on the canvas to create '
1122                    'a new text annotation item.</p>'
1123#                    '<a href="help://orange-canvas/text-annotations">'
1124#                    'More ...</a>'
1125                    )
1126        )
1127        QCoreApplication.postEvent(self.document, helpevent)
1128
[11151]1129        UserInteraction.start(self)
1130
[11193]1131    def createNewAnnotation(self, rect):
[11443]1132        """
1133        Create a new TextAnnotation at with `rect` as the geometry.
[11193]1134        """
1135        annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
[11202]1136        font = {"family": unicode(self.font.family()),
[11343]1137                "size": self.font.pixelSize()}
[11202]1138        annot.set_font(font)
[11193]1139
1140        item = self.scene.add_annotation(annot)
1141        item.setTextInteractionFlags(Qt.TextEditorInteraction)
1142        item.setFramePen(QPen(Qt.DashLine))
1143
1144        self.annotation_item = item
1145        self.annotation = annot
1146        self.control = controlpoints.ControlPointRect()
1147        self.control.rectChanged.connect(
1148            self.annotation_item.setGeometry
1149        )
1150        self.scene.addItem(self.control)
1151
[11151]1152    def mousePressEvent(self, event):
[11113]1153        if event.button() == Qt.LeftButton:
1154            self.down_pos = event.scenePos()
1155            return True
1156
[11151]1157    def mouseMoveEvent(self, event):
[11113]1158        if event.buttons() & Qt.LeftButton:
1159            if self.annotation_item is None and \
1160                    (self.down_pos - event.scenePos()).manhattanLength() > \
1161                    QApplication.instance().startDragDistance():
1162                rect = QRectF(self.down_pos, event.scenePos()).normalized()
[11193]1163                self.createNewAnnotation(rect)
[11113]1164
1165            if self.annotation_item is not None:
1166                rect = QRectF(self.down_pos, event.scenePos()).normalized()
[11162]1167                self.control.setRect(rect)
1168
[11113]1169            return True
1170
[11151]1171    def mouseReleaseEvent(self, event):
[11113]1172        if event.button() == Qt.LeftButton:
[11193]1173            if self.annotation_item is None:
1174                self.createNewAnnotation(QRectF(event.scenePos(),
1175                                                event.scenePos()))
1176                rect = self.defaultTextGeometry(event.scenePos())
1177
1178            else:
[11113]1179                rect = QRectF(self.down_pos, event.scenePos()).normalized()
1180
[11193]1181            # Commit the annotation to the scheme.
1182            self.annotation.rect = rect_to_tuple(rect)
[11113]1183
[11193]1184            self.document.addAnnotation(self.annotation)
[11113]1185
[11193]1186            self.annotation_item.setGeometry(rect)
[11162]1187
[11193]1188            self.control.rectChanged.disconnect(
1189                self.annotation_item.setGeometry
1190            )
1191            self.control.hide()
1192
1193            # Move the focus to the editor.
1194            self.annotation_item.setFramePen(QPen(Qt.NoPen))
1195            self.annotation_item.setFocus(Qt.OtherFocusReason)
1196            self.annotation_item.startEdit()
[11113]1197
1198            self.end()
1199
[11193]1200    def defaultTextGeometry(self, point):
[11443]1201        """
1202        Return the default text geometry. Used in case the user single
1203        clicked in the scene.
[11193]1204
1205        """
1206        font = self.annotation_item.font()
1207        metrics = QFontMetrics(font)
[11196]1208        spacing = metrics.lineSpacing()
1209        margin = self.annotation_item.document().documentMargin()
1210
1211        rect = QRectF(QPointF(point.x(), point.y() - spacing - margin),
1212                      QSizeF(150, spacing + 2 * margin))
[11193]1213        return rect
1214
[11113]1215    def end(self):
[11162]1216        if self.control is not None:
1217            self.scene.removeItem(self.control)
[11192]1218
[11162]1219        self.control = None
[11113]1220        self.down_pos = None
1221        self.annotation_item = None
1222        self.annotation = None
[11151]1223        self.document.view().setCursor(Qt.ArrowCursor)
[11272]1224
1225        # Clear the help tip
1226        helpevent = QuickHelpTipEvent("", "")
1227        QCoreApplication.postEvent(self.document, helpevent)
1228
[11113]1229        UserInteraction.end(self)
[11162]1230
1231
1232class ResizeTextAnnotation(UserInteraction):
[11443]1233    """
1234    Resize a Text Annotation interaction handler.
1235    """
[11195]1236    def __init__(self, document, *args, **kwargs):
1237        UserInteraction.__init__(self, document, *args, **kwargs)
[11162]1238        self.item = None
1239        self.annotation = None
1240        self.control = None
1241        self.savedFramePen = None
[11192]1242        self.savedRect = None
[11162]1243
1244    def mousePressEvent(self, event):
1245        pos = event.scenePos()
1246        if self.item is None:
1247            item = self.scene.item_at(pos, items.TextAnnotation)
1248            if item is not None and not item.hasFocus():
1249                self.editItem(item)
[11192]1250                return False
[11162]1251
1252        return UserInteraction.mousePressEvent(self, event)
1253
1254    def editItem(self, item):
1255        annotation = self.scene.annotation_for_item(item)
1256        rect = item.geometry()  # TODO: map to scene if item has a parent.
1257        control = controlpoints.ControlPointRect(rect=rect)
1258        self.scene.addItem(control)
1259
1260        self.savedFramePen = item.framePen()
[11192]1261        self.savedRect = rect
[11162]1262
1263        control.rectEdited.connect(item.setGeometry)
[11192]1264        control.setFocusProxy(item)
[11162]1265
[11185]1266        item.setFramePen(QPen(Qt.DashDotLine))
[11192]1267        item.geometryChanged.connect(self.__on_textGeometryChanged)
[11162]1268
1269        self.item = item
1270
1271        self.annotation = annotation
1272        self.control = control
1273
[11192]1274    def commit(self):
[11443]1275        """
1276        Commit the current item geometry state to the document.
[11192]1277        """
1278        rect = self.item.geometry()
1279        if self.savedRect != rect:
1280            command = commands.SetAttrCommand(
1281                self.annotation, "rect",
1282                (rect.x(), rect.y(), rect.width(), rect.height()),
1283                name="Edit text geometry"
1284            )
1285            self.document.undoStack().push(command)
1286            self.savedRect = rect
1287
[11162]1288    def __on_editingFinished(self):
[11192]1289        self.commit()
[11162]1290        self.end()
1291
1292    def __on_rectEdited(self, rect):
1293        self.item.setGeometry(rect)
1294
[11192]1295    def __on_textGeometryChanged(self):
1296        if not self.control.isControlActive():
1297            rect = self.item.geometry()
1298            self.control.setRect(rect)
1299
[11195]1300    def cancel(self, reason=UserInteraction.OtherReason):
1301        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
[11192]1302        if self.item is not None and self.savedRect is not None:
1303            self.item.setGeometry(self.savedRect)
[11162]1304
[11195]1305        UserInteraction.cancel(self, reason)
[11162]1306
1307    def end(self):
1308        if self.control is not None:
1309            self.scene.removeItem(self.control)
1310
1311        if self.item is not None:
1312            self.item.setFramePen(self.savedFramePen)
1313
1314        self.item = None
1315        self.annotation = None
1316        self.control = None
1317
1318        UserInteraction.end(self)
1319
1320
1321class ResizeArrowAnnotation(UserInteraction):
[11443]1322    """
1323    Resize an Arrow Annotation interaction handler.
1324    """
[11195]1325    def __init__(self, document, *args, **kwargs):
1326        UserInteraction.__init__(self, document, *args, **kwargs)
[11162]1327        self.item = None
1328        self.annotation = None
1329        self.control = None
[11192]1330        self.savedLine = None
[11162]1331
1332    def mousePressEvent(self, event):
1333        pos = event.scenePos()
1334        if self.item is None:
1335            item = self.scene.item_at(pos, items.ArrowAnnotation)
[11185]1336            if item is not None and not item.hasFocus():
[11162]1337                self.editItem(item)
[11192]1338                return False
[11162]1339
1340        return UserInteraction.mousePressEvent(self, event)
1341
1342    def editItem(self, item):
1343        annotation = self.scene.annotation_for_item(item)
1344        control = controlpoints.ControlPointLine()
1345        self.scene.addItem(control)
1346
1347        line = item.line()
[11192]1348        self.savedLine = line
[11162]1349
1350        p1, p2 = map(item.mapToScene, (line.p1(), line.p2()))
1351
1352        control.setLine(QLineF(p1, p2))
[11192]1353        control.setFocusProxy(item)
[11162]1354        control.lineEdited.connect(self.__on_lineEdited)
1355
[11192]1356        item.geometryChanged.connect(self.__on_lineGeometryChanged)
1357
[11162]1358        self.item = item
1359        self.annotation = annotation
1360        self.control = control
1361
[11192]1362    def commit(self):
1363        """Commit the current geometry of the item to the document.
1364
1365        .. note:: Does nothing if the actual geometry is not changed.
1366
1367        """
[11162]1368        line = self.control.line()
1369        p1, p2 = line.p1(), line.p2()
1370
[11192]1371        if self.item.line() != self.savedLine:
1372            command = commands.SetAttrCommand(
1373                self.annotation,
1374                "geometry",
1375                ((p1.x(), p1.y()), (p2.x(), p2.y())),
1376                name="Edit arrow geometry",
1377            )
1378            self.document.undoStack().push(command)
1379            self.savedLine = self.item.line()
[11162]1380
[11192]1381    def __on_editingFinished(self):
1382        self.commit()
[11162]1383        self.end()
1384
1385    def __on_lineEdited(self, line):
1386        p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2()))
1387        self.item.setLine(QLineF(p1, p2))
1388
[11192]1389    def __on_lineGeometryChanged(self):
1390        # Possible geometry change from out of our control, for instance
1391        # item move as a part of a selection group.
1392        if not self.control.isControlActive():
1393            line = self.item.line()
1394            p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2()))
1395            self.control.setLine(QLineF(p1, p2))
1396
[11195]1397    def cancel(self, reason=UserInteraction.OtherReason):
1398        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
[11192]1399        if self.item is not None and self.savedLine is not None:
1400            self.item.setLine(self.savedLine)
[11162]1401
[11195]1402        UserInteraction.cancel(self, reason)
[11162]1403
1404    def end(self):
1405        if self.control is not None:
1406            self.scene.removeItem(self.control)
1407
[11192]1408        if self.item is not None:
1409            self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged)
1410
[11162]1411        self.control = None
1412        self.item = None
1413        self.annotation = None
1414
1415        UserInteraction.end(self)
Note: See TracBrowser for help on using the repository browser.