source: orange/Orange/OrangeCanvas/document/interactions.py @ 11351:5e03767bf986

Revision 11351:5e03767bf986, 38.8 KB checked in by markotoplak, 14 months ago (diff)

When dragging widgets, a a new widget should be placed so that the connection stays as it was.

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