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

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

Added docstring documentation for canvas interactions.

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