source: orange/Orange/OrangeCanvas/document/interactions.py @ 11276:c67cafd56079

Revision 11276:c67cafd56079, 37.5 KB checked in by Ales Erjavec <ales.erjavec@…>, 15 months ago (diff)

Remove existing links into a 'single' connection sink channel.

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                    links_action = EditNodeLinksAction(
427                                    self.document, source_node, sink_node)
428                    try:
429                        links_action.edit_links()
430                    except Exception:
431                        log.error("'EditNodeLinksAction' failed",
432                                  exc_info=True)
433                        raise
434                    # EditNodeLinksAction already commits the links on accepted
435                    links_to_add = []
436
437            for source, sink in links_to_add:
438                if sink.single:
439                    # Remove an existing link to the sink channel if present.
440                    existing_link = self.scheme.find_links(
441                        sink_node=sink_node, sink_channel=sink
442                    )
443
444                    if existing_link:
445                        self.document.removeLink(existing_link[0])
446
447                # Check if the new link is a duplicate of an existing link
448                duplicate = self.scheme.find_links(
449                    source_node, source, sink_node, sink
450                )
451
452                if duplicate:
453                    # Do nothing.
454                    continue
455
456                # Remove temp items before creating a new link
457                self.cleanup()
458
459                link = scheme.SchemeLink(source_node, source, sink_node, sink)
460                self.document.addLink(link)
461
462        except scheme.IncompatibleChannelTypeError:
463            log.info("Cannot connect: invalid channel types.")
464            self.cancel()
465        except scheme.SchemeTopologyError:
466            log.info("Cannot connect: connection creates a cycle.")
467            self.cancel()
468        except NoPossibleLinksError:
469            log.info("Cannot connect: no possible links.")
470            self.cancel()
471        except Exception:
472            log.error("An error occurred during the creation of a new link.",
473                      exc_info=True)
474            self.cancel()
475
476        if not self.isFinished():
477            self.end()
478
479    def end(self):
480        self.cleanup()
481        helpevent = QuickHelpTipEvent("", "")
482        QCoreApplication.postEvent(self.document, helpevent)
483        UserInteraction.end(self)
484
485    def cancel(self, reason=UserInteraction.OtherReason):
486        self.cleanup()
487        UserInteraction.cancel(self, reason)
488
489    def cleanup(self):
490        """Cleanup all temp items in the scene that are left.
491        """
492        if self.tmp_link_item:
493            self.tmp_link_item.setSinkItem(None)
494            self.tmp_link_item.setSourceItem(None)
495
496            if self.tmp_link_item.scene():
497                self.scene.removeItem(self.tmp_link_item)
498
499            self.tmp_link_item = None
500
501        if self.current_target_item:
502            self.remove_tmp_anchor()
503            self.current_target_item = None
504
505        if self.cursor_anchor_point and self.cursor_anchor_point.scene():
506            self.scene.removeItem(self.cursor_anchor_point)
507            self.cursor_anchor_point = None
508
509
510class NewNodeAction(UserInteraction):
511    """Present the user with a quick menu for node selection and
512    create the selected node.
513
514    """
515
516    def mousePressEvent(self, event):
517        if event.button() == Qt.RightButton:
518            self.create_new(event.screenPos())
519            self.end()
520
521    def create_new(self, pos):
522        """Create a new widget with a QuickWidgetMenu at `pos`
523        (in screen coordinates).
524
525        """
526        menu = self.document.quickMenu()
527        menu.setFilterFunc(None)
528
529        action = menu.exec_(pos)
530        if action:
531            item = action.property("item").toPyObject()
532            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
533            # Get the scene position
534            view = self.document.view()
535            pos = view.mapToScene(view.mapFromGlobal(pos))
536            node = scheme.SchemeNode(desc, position=(pos.x(), pos.y()))
537            self.document.addNode(node)
538            return node
539
540
541class RectangleSelectionAction(UserInteraction):
542    """Select items in the scene using a Rectangle selection
543    """
544    def __init__(self, document, *args, **kwargs):
545        UserInteraction.__init__(self, document, *args, **kwargs)
546        self.initial_selection = None
547        self.last_selection = None
548        self.selection_rect = None
549        self.modifiers = 0
550
551    def mousePressEvent(self, event):
552        pos = event.scenePos()
553        any_item = self.scene.item_at(pos)
554        if not any_item and event.button() & Qt.LeftButton:
555            self.modifiers = event.modifiers()
556            self.selection_rect = QRectF(pos, QSizeF(0, 0))
557            self.rect_item = QGraphicsRectItem(
558                self.selection_rect.normalized()
559            )
560
561            self.rect_item.setPen(
562                QPen(QBrush(QColor(51, 153, 255, 192)),
563                     0.4, Qt.SolidLine, Qt.RoundCap)
564            )
565
566            self.rect_item.setBrush(
567                QBrush(QColor(168, 202, 236, 192))
568            )
569
570            self.rect_item.setZValue(-100)
571
572            # Clear the focus if necessary.
573            if not self.scene.stickyFocus():
574                self.scene.clearFocus()
575
576            if not self.modifiers & Qt.ControlModifier:
577                self.scene.clearSelection()
578
579            event.accept()
580            return True
581        else:
582            self.cancel(self.ErrorReason)
583            return False
584
585    def mouseMoveEvent(self, event):
586        if not self.rect_item.scene():
587            self.scene.addItem(self.rect_item)
588        self.update_selection(event)
589        return True
590
591    def mouseReleaseEvent(self, event):
592        if event.button() == Qt.LeftButton:
593            if self.initial_selection is None:
594                # A single click.
595                self.scene.clearSelection()
596            else:
597                self.update_selection(event)
598        self.end()
599        return True
600
601    def update_selection(self, event):
602        if self.initial_selection is None:
603            self.initial_selection = set(self.scene.selectedItems())
604            self.last_selection = self.initial_selection
605
606        pos = event.scenePos()
607        self.selection_rect = QRectF(self.selection_rect.topLeft(), pos)
608
609        rect = self._bound_selection_rect(self.selection_rect.normalized())
610
611        # Need that constant otherwise the sceneRect will still grow
612        pw = self.rect_item.pen().width() + 0.5
613
614        self.rect_item.setRect(rect.adjusted(pw, pw, -pw, -pw))
615
616        selected = self.scene.items(self.selection_rect.normalized(),
617                                    Qt.IntersectsItemShape,
618                                    Qt.AscendingOrder)
619
620        selected = set([item for item in selected if \
621                        item.flags() & Qt.ItemIsSelectable])
622
623        if self.modifiers & Qt.ControlModifier:
624            for item in selected | self.last_selection | \
625                    self.initial_selection:
626                item.setSelected(
627                    (item in selected) ^ (item in self.initial_selection)
628                )
629        else:
630            for item in selected.union(self.last_selection):
631                item.setSelected(item in selected)
632
633        self.last_selection = set(self.scene.selectedItems())
634
635    def end(self):
636        self.initial_selection = None
637        self.last_selection = None
638        self.modifiers = 0
639
640        self.rect_item.hide()
641        if self.rect_item.scene() is not None:
642            self.scene.removeItem(self.rect_item)
643        UserInteraction.end(self)
644
645    def viewport_rect(self):
646        """Return the bounding rect of the document's viewport on the
647        scene.
648
649        """
650        view = self.document.view()
651        vsize = view.viewport().size()
652        viewportrect = QRect(0, 0, vsize.width(), vsize.height())
653        return view.mapToScene(viewportrect).boundingRect()
654
655    def _bound_selection_rect(self, rect):
656        """Bound the selection `rect` to a sensible size.
657        """
658        srect = self.scene.sceneRect()
659        vrect = self.viewport_rect()
660        maxrect = srect.united(vrect)
661        return rect.intersected(maxrect)
662
663
664class EditNodeLinksAction(UserInteraction):
665    def __init__(self, document, source_node, sink_node, *args, **kwargs):
666        UserInteraction.__init__(self, document, *args, **kwargs)
667        self.source_node = source_node
668        self.sink_node = sink_node
669
670    def edit_links(self):
671        from ..canvas.editlinksdialog import EditLinksDialog
672
673        log.info("Constructing a Link Editor dialog.")
674
675        parent = self.scene.views()[0]
676        dlg = EditLinksDialog(parent)
677
678        links = self.scheme.find_links(source_node=self.source_node,
679                                       sink_node=self.sink_node)
680        existing_links = [(link.source_channel, link.sink_channel)
681                          for link in links]
682
683        dlg.setNodes(self.source_node, self.sink_node)
684        dlg.setLinks(existing_links)
685
686        log.info("Executing a Link Editor Dialog.")
687        rval = dlg.exec_()
688
689        if rval == EditLinksDialog.Accepted:
690            links = dlg.links()
691
692            links_to_add = set(links) - set(existing_links)
693            links_to_remove = set(existing_links) - set(links)
694
695            stack = self.document.undoStack()
696            stack.beginMacro("Edit Links")
697
698            # First remove links into a single sink channel,
699            # but only the ones that do not have self.source_node as
700            # a source (they will be removed later from links_to_remove)
701            for _, sink_channel in links_to_add:
702                if sink_channel.single:
703                    existing = self.scheme.find_links(
704                        sink_node=self.sink_node,
705                        sink_channel=sink_channel
706                    )
707
708                    existing = [link for link in existing
709                                if link.source_node is not self.source_node]
710
711                    if existing:
712                        assert len(existing) == 1
713                        self.document.removeLink(existing[0])
714
715            for source_channel, sink_channel in links_to_remove:
716                links = self.scheme.find_links(source_node=self.source_node,
717                                               source_channel=source_channel,
718                                               sink_node=self.sink_node,
719                                               sink_channel=sink_channel)
720                assert len(links) == 1
721                self.document.removeLink(links[0])
722
723            for source_channel, sink_channel in links_to_add:
724                link = scheme.SchemeLink(self.source_node, source_channel,
725                                         self.sink_node, sink_channel)
726
727                self.document.addLink(link)
728
729            stack.endMacro()
730
731
732def point_to_tuple(point):
733    return point.x(), point.y()
734
735
736class NewArrowAnnotation(UserInteraction):
737    """Create a new arrow annotation.
738    """
739    def __init__(self, document, *args, **kwargs):
740        UserInteraction.__init__(self, document, *args, **kwargs)
741        self.down_pos = None
742        self.arrow_item = None
743        self.annotation = None
744        self.color = "red"
745
746    def start(self):
747        self.document.view().setCursor(Qt.CrossCursor)
748
749        helpevent = QuickHelpTipEvent(
750            self.tr("Click and drag to create a new arrow"),
751            self.tr('<h3>New arrow annotation</h3>'
752                    '<p>Click and drag to create a new arrow annotation</p>'
753#                    '<a href="help://orange-canvas/arrow-annotations>'
754#                    'More ...</a>'
755                    )
756        )
757        QCoreApplication.postEvent(self.document, helpevent)
758
759        UserInteraction.start(self)
760
761    def setColor(self, color):
762        self.color = color
763
764    def mousePressEvent(self, event):
765        if event.button() == Qt.LeftButton:
766            self.down_pos = event.scenePos()
767            event.accept()
768            return True
769
770    def mouseMoveEvent(self, event):
771        if event.buttons() & Qt.LeftButton:
772            if self.arrow_item is None and \
773                    (self.down_pos - event.scenePos()).manhattanLength() > \
774                    QApplication.instance().startDragDistance():
775
776                annot = scheme.SchemeArrowAnnotation(
777                    point_to_tuple(self.down_pos),
778                    point_to_tuple(event.scenePos())
779                )
780                annot.set_color(self.color)
781                item = self.scene.add_annotation(annot)
782
783                self.arrow_item = item
784                self.annotation = annot
785
786            if self.arrow_item is not None:
787                p1, p2 = map(self.arrow_item.mapFromScene,
788                             (self.down_pos, event.scenePos()))
789                self.arrow_item.setLine(QLineF(p1, p2))
790                self.arrow_item.adjustGeometry()
791
792            event.accept()
793            return True
794
795    def mouseReleaseEvent(self, event):
796        if event.button() == Qt.LeftButton:
797            if self.arrow_item is not None:
798                p1, p2 = self.down_pos, event.scenePos()
799
800                # Commit the annotation to the scheme
801                self.annotation.set_line(point_to_tuple(p1),
802                                         point_to_tuple(p2))
803
804                self.document.addAnnotation(self.annotation)
805
806                p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2))
807                self.arrow_item.setLine(QLineF(p1, p2))
808                self.arrow_item.adjustGeometry()
809
810            self.end()
811            return True
812
813    def end(self):
814        self.down_pos = None
815        self.arrow_item = None
816        self.annotation = None
817        self.document.view().setCursor(Qt.ArrowCursor)
818
819        # Clear the help tip
820        helpevent = QuickHelpTipEvent("", "")
821        QCoreApplication.postEvent(self.document, helpevent)
822
823        UserInteraction.end(self)
824
825
826def rect_to_tuple(rect):
827    return rect.x(), rect.y(), rect.width(), rect.height()
828
829
830class NewTextAnnotation(UserInteraction):
831    def __init__(self, document, *args, **kwargs):
832        UserInteraction.__init__(self, document, *args, **kwargs)
833        self.down_pos = None
834        self.annotation_item = None
835        self.annotation = None
836        self.control = None
837        self.font = document.font()
838
839    def setFont(self, font):
840        self.font = font
841
842    def start(self):
843        self.document.view().setCursor(Qt.CrossCursor)
844
845        helpevent = QuickHelpTipEvent(
846            self.tr("Click to create a new text annotation"),
847            self.tr('<h3>New text annotation</h3>'
848                    '<p>Click (and drag to resize) on the canvas to create '
849                    'a new text annotation item.</p>'
850#                    '<a href="help://orange-canvas/text-annotations">'
851#                    'More ...</a>'
852                    )
853        )
854        QCoreApplication.postEvent(self.document, helpevent)
855
856        UserInteraction.start(self)
857
858    def createNewAnnotation(self, rect):
859        """Create a new TextAnnotation at with `rect` as the geometry.
860        """
861        annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
862        font = {"family": unicode(self.font.family()),
863                "size": self.font.pointSize()}
864        annot.set_font(font)
865
866        item = self.scene.add_annotation(annot)
867        item.setTextInteractionFlags(Qt.TextEditorInteraction)
868        item.setFramePen(QPen(Qt.DashLine))
869
870        self.annotation_item = item
871        self.annotation = annot
872        self.control = controlpoints.ControlPointRect()
873        self.control.rectChanged.connect(
874            self.annotation_item.setGeometry
875        )
876        self.scene.addItem(self.control)
877
878    def mousePressEvent(self, event):
879        if event.button() == Qt.LeftButton:
880            self.down_pos = event.scenePos()
881            return True
882
883    def mouseMoveEvent(self, event):
884        if event.buttons() & Qt.LeftButton:
885            if self.annotation_item is None and \
886                    (self.down_pos - event.scenePos()).manhattanLength() > \
887                    QApplication.instance().startDragDistance():
888                rect = QRectF(self.down_pos, event.scenePos()).normalized()
889                self.createNewAnnotation(rect)
890
891            if self.annotation_item is not None:
892                rect = QRectF(self.down_pos, event.scenePos()).normalized()
893                self.control.setRect(rect)
894
895            return True
896
897    def mouseReleaseEvent(self, event):
898        if event.button() == Qt.LeftButton:
899            if self.annotation_item is None:
900                self.createNewAnnotation(QRectF(event.scenePos(),
901                                                event.scenePos()))
902                rect = self.defaultTextGeometry(event.scenePos())
903
904            else:
905                rect = QRectF(self.down_pos, event.scenePos()).normalized()
906
907            # Commit the annotation to the scheme.
908            self.annotation.rect = rect_to_tuple(rect)
909
910            self.document.addAnnotation(self.annotation)
911
912            self.annotation_item.setGeometry(rect)
913
914            self.control.rectChanged.disconnect(
915                self.annotation_item.setGeometry
916            )
917            self.control.hide()
918
919            # Move the focus to the editor.
920            self.annotation_item.setFramePen(QPen(Qt.NoPen))
921            self.annotation_item.setFocus(Qt.OtherFocusReason)
922            self.annotation_item.startEdit()
923
924            self.end()
925
926    def defaultTextGeometry(self, point):
927        """Return the default text geometry. Used in case the user
928        single clicked in the scene.
929
930        """
931        font = self.annotation_item.font()
932        metrics = QFontMetrics(font)
933        spacing = metrics.lineSpacing()
934        margin = self.annotation_item.document().documentMargin()
935
936        rect = QRectF(QPointF(point.x(), point.y() - spacing - margin),
937                      QSizeF(150, spacing + 2 * margin))
938        return rect
939
940    def end(self):
941        if self.control is not None:
942            self.scene.removeItem(self.control)
943
944        self.control = None
945        self.down_pos = None
946        self.annotation_item = None
947        self.annotation = None
948        self.document.view().setCursor(Qt.ArrowCursor)
949
950        # Clear the help tip
951        helpevent = QuickHelpTipEvent("", "")
952        QCoreApplication.postEvent(self.document, helpevent)
953
954        UserInteraction.end(self)
955
956
957class ResizeTextAnnotation(UserInteraction):
958    def __init__(self, document, *args, **kwargs):
959        UserInteraction.__init__(self, document, *args, **kwargs)
960        self.item = None
961        self.annotation = None
962        self.control = None
963        self.savedFramePen = None
964        self.savedRect = None
965
966    def mousePressEvent(self, event):
967        pos = event.scenePos()
968        if self.item is None:
969            item = self.scene.item_at(pos, items.TextAnnotation)
970            if item is not None and not item.hasFocus():
971                self.editItem(item)
972                return False
973
974        return UserInteraction.mousePressEvent(self, event)
975
976    def editItem(self, item):
977        annotation = self.scene.annotation_for_item(item)
978        rect = item.geometry()  # TODO: map to scene if item has a parent.
979        control = controlpoints.ControlPointRect(rect=rect)
980        self.scene.addItem(control)
981
982        self.savedFramePen = item.framePen()
983        self.savedRect = rect
984
985        control.rectEdited.connect(item.setGeometry)
986        control.setFocusProxy(item)
987
988        item.setFramePen(QPen(Qt.DashDotLine))
989        item.geometryChanged.connect(self.__on_textGeometryChanged)
990
991        self.item = item
992
993        self.annotation = annotation
994        self.control = control
995
996    def commit(self):
997        """Commit the current item geometry state to the document.
998        """
999        rect = self.item.geometry()
1000        if self.savedRect != rect:
1001            command = commands.SetAttrCommand(
1002                self.annotation, "rect",
1003                (rect.x(), rect.y(), rect.width(), rect.height()),
1004                name="Edit text geometry"
1005            )
1006            self.document.undoStack().push(command)
1007            self.savedRect = rect
1008
1009    def __on_editingFinished(self):
1010        self.commit()
1011        self.end()
1012
1013    def __on_rectEdited(self, rect):
1014        self.item.setGeometry(rect)
1015
1016    def __on_textGeometryChanged(self):
1017        if not self.control.isControlActive():
1018            rect = self.item.geometry()
1019            self.control.setRect(rect)
1020
1021    def cancel(self, reason=UserInteraction.OtherReason):
1022        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1023        if self.item is not None and self.savedRect is not None:
1024            self.item.setGeometry(self.savedRect)
1025
1026        UserInteraction.cancel(self, reason)
1027
1028    def end(self):
1029        if self.control is not None:
1030            self.scene.removeItem(self.control)
1031
1032        if self.item is not None:
1033            self.item.setFramePen(self.savedFramePen)
1034
1035        self.item = None
1036        self.annotation = None
1037        self.control = None
1038
1039        UserInteraction.end(self)
1040
1041
1042class ResizeArrowAnnotation(UserInteraction):
1043    def __init__(self, document, *args, **kwargs):
1044        UserInteraction.__init__(self, document, *args, **kwargs)
1045        self.item = None
1046        self.annotation = None
1047        self.control = None
1048        self.savedLine = None
1049
1050    def mousePressEvent(self, event):
1051        pos = event.scenePos()
1052        if self.item is None:
1053            item = self.scene.item_at(pos, items.ArrowAnnotation)
1054            if item is not None and not item.hasFocus():
1055                self.editItem(item)
1056                return False
1057
1058        return UserInteraction.mousePressEvent(self, event)
1059
1060    def editItem(self, item):
1061        annotation = self.scene.annotation_for_item(item)
1062        control = controlpoints.ControlPointLine()
1063        self.scene.addItem(control)
1064
1065        line = item.line()
1066        self.savedLine = line
1067
1068        p1, p2 = map(item.mapToScene, (line.p1(), line.p2()))
1069
1070        control.setLine(QLineF(p1, p2))
1071        control.setFocusProxy(item)
1072        control.lineEdited.connect(self.__on_lineEdited)
1073
1074        item.geometryChanged.connect(self.__on_lineGeometryChanged)
1075
1076        self.item = item
1077        self.annotation = annotation
1078        self.control = control
1079
1080    def commit(self):
1081        """Commit the current geometry of the item to the document.
1082
1083        .. note:: Does nothing if the actual geometry is not changed.
1084
1085        """
1086        line = self.control.line()
1087        p1, p2 = line.p1(), line.p2()
1088
1089        if self.item.line() != self.savedLine:
1090            command = commands.SetAttrCommand(
1091                self.annotation,
1092                "geometry",
1093                ((p1.x(), p1.y()), (p2.x(), p2.y())),
1094                name="Edit arrow geometry",
1095            )
1096            self.document.undoStack().push(command)
1097            self.savedLine = self.item.line()
1098
1099    def __on_editingFinished(self):
1100        self.commit()
1101        self.end()
1102
1103    def __on_lineEdited(self, line):
1104        p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2()))
1105        self.item.setLine(QLineF(p1, p2))
1106        self.item.adjustGeometry()
1107
1108    def __on_lineGeometryChanged(self):
1109        # Possible geometry change from out of our control, for instance
1110        # item move as a part of a selection group.
1111        if not self.control.isControlActive():
1112            line = self.item.line()
1113            p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2()))
1114            self.control.setLine(QLineF(p1, p2))
1115
1116    def cancel(self, reason=UserInteraction.OtherReason):
1117        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1118        if self.item is not None and self.savedLine is not None:
1119            self.item.setLine(self.savedLine)
1120
1121        UserInteraction.cancel(self, reason)
1122
1123    def end(self):
1124        if self.control is not None:
1125            self.scene.removeItem(self.control)
1126
1127        if self.item is not None:
1128            self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged)
1129
1130        self.control = None
1131        self.item = None
1132        self.annotation = None
1133
1134        UserInteraction.end(self)
Note: See TracBrowser for help on using the repository browser.