source: orange/Orange/OrangeCanvas/document/interactions.py @ 11450:e9c545633850

Revision 11450:e9c545633850, 47.1 KB checked in by Ales Erjavec <ales.erjavec@…>, 13 months ago (diff)

Made NewLinkAction an atomic operation.

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