source: orange/Orange/OrangeCanvas/document/interactions.py @ 11277:3cb2c6af3ced

Revision 11277:3cb2c6af3ced, 38.3 KB checked in by Ales Erjavec <ales.erjavec@…>, 15 months ago (diff)

Set initial shown links in link edit dialog when creating a new link.

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