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

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

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

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