source: orange/Orange/OrangeCanvas/document/interactions.py @ 11401:c12e0617fe38

Revision 11401:c12e0617fe38, 38.6 KB checked in by Ales Erjavec <ales.erjavec@…>, 13 months ago (diff)

Added 'autoAdjustGeometry' option to ArrowAnnotation class.

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
820            event.accept()
821            return True
822
823    def mouseReleaseEvent(self, event):
824        if event.button() == Qt.LeftButton:
825            if self.arrow_item is not None:
826                p1, p2 = self.down_pos, event.scenePos()
827
828                # Commit the annotation to the scheme
829                self.annotation.set_line(point_to_tuple(p1),
830                                         point_to_tuple(p2))
831
832                self.document.addAnnotation(self.annotation)
833
834                p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2))
835                self.arrow_item.setLine(QLineF(p1, p2))
836
837            self.end()
838            return True
839
840    def end(self):
841        self.down_pos = None
842        self.arrow_item = None
843        self.annotation = None
844        self.document.view().setCursor(Qt.ArrowCursor)
845
846        # Clear the help tip
847        helpevent = QuickHelpTipEvent("", "")
848        QCoreApplication.postEvent(self.document, helpevent)
849
850        UserInteraction.end(self)
851
852
853def rect_to_tuple(rect):
854    return rect.x(), rect.y(), rect.width(), rect.height()
855
856
857class NewTextAnnotation(UserInteraction):
858    def __init__(self, document, *args, **kwargs):
859        UserInteraction.__init__(self, document, *args, **kwargs)
860        self.down_pos = None
861        self.annotation_item = None
862        self.annotation = None
863        self.control = None
864        self.font = document.font()
865
866    def setFont(self, font):
867        self.font = font
868
869    def start(self):
870        self.document.view().setCursor(Qt.CrossCursor)
871
872        helpevent = QuickHelpTipEvent(
873            self.tr("Click to create a new text annotation"),
874            self.tr('<h3>New text annotation</h3>'
875                    '<p>Click (and drag to resize) on the canvas to create '
876                    'a new text annotation item.</p>'
877#                    '<a href="help://orange-canvas/text-annotations">'
878#                    'More ...</a>'
879                    )
880        )
881        QCoreApplication.postEvent(self.document, helpevent)
882
883        UserInteraction.start(self)
884
885    def createNewAnnotation(self, rect):
886        """Create a new TextAnnotation at with `rect` as the geometry.
887        """
888        annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
889        font = {"family": unicode(self.font.family()),
890                "size": self.font.pixelSize()}
891        annot.set_font(font)
892
893        item = self.scene.add_annotation(annot)
894        item.setTextInteractionFlags(Qt.TextEditorInteraction)
895        item.setFramePen(QPen(Qt.DashLine))
896
897        self.annotation_item = item
898        self.annotation = annot
899        self.control = controlpoints.ControlPointRect()
900        self.control.rectChanged.connect(
901            self.annotation_item.setGeometry
902        )
903        self.scene.addItem(self.control)
904
905    def mousePressEvent(self, event):
906        if event.button() == Qt.LeftButton:
907            self.down_pos = event.scenePos()
908            return True
909
910    def mouseMoveEvent(self, event):
911        if event.buttons() & Qt.LeftButton:
912            if self.annotation_item is None and \
913                    (self.down_pos - event.scenePos()).manhattanLength() > \
914                    QApplication.instance().startDragDistance():
915                rect = QRectF(self.down_pos, event.scenePos()).normalized()
916                self.createNewAnnotation(rect)
917
918            if self.annotation_item is not None:
919                rect = QRectF(self.down_pos, event.scenePos()).normalized()
920                self.control.setRect(rect)
921
922            return True
923
924    def mouseReleaseEvent(self, event):
925        if event.button() == Qt.LeftButton:
926            if self.annotation_item is None:
927                self.createNewAnnotation(QRectF(event.scenePos(),
928                                                event.scenePos()))
929                rect = self.defaultTextGeometry(event.scenePos())
930
931            else:
932                rect = QRectF(self.down_pos, event.scenePos()).normalized()
933
934            # Commit the annotation to the scheme.
935            self.annotation.rect = rect_to_tuple(rect)
936
937            self.document.addAnnotation(self.annotation)
938
939            self.annotation_item.setGeometry(rect)
940
941            self.control.rectChanged.disconnect(
942                self.annotation_item.setGeometry
943            )
944            self.control.hide()
945
946            # Move the focus to the editor.
947            self.annotation_item.setFramePen(QPen(Qt.NoPen))
948            self.annotation_item.setFocus(Qt.OtherFocusReason)
949            self.annotation_item.startEdit()
950
951            self.end()
952
953    def defaultTextGeometry(self, point):
954        """Return the default text geometry. Used in case the user
955        single clicked in the scene.
956
957        """
958        font = self.annotation_item.font()
959        metrics = QFontMetrics(font)
960        spacing = metrics.lineSpacing()
961        margin = self.annotation_item.document().documentMargin()
962
963        rect = QRectF(QPointF(point.x(), point.y() - spacing - margin),
964                      QSizeF(150, spacing + 2 * margin))
965        return rect
966
967    def end(self):
968        if self.control is not None:
969            self.scene.removeItem(self.control)
970
971        self.control = None
972        self.down_pos = None
973        self.annotation_item = None
974        self.annotation = None
975        self.document.view().setCursor(Qt.ArrowCursor)
976
977        # Clear the help tip
978        helpevent = QuickHelpTipEvent("", "")
979        QCoreApplication.postEvent(self.document, helpevent)
980
981        UserInteraction.end(self)
982
983
984class ResizeTextAnnotation(UserInteraction):
985    def __init__(self, document, *args, **kwargs):
986        UserInteraction.__init__(self, document, *args, **kwargs)
987        self.item = None
988        self.annotation = None
989        self.control = None
990        self.savedFramePen = None
991        self.savedRect = None
992
993    def mousePressEvent(self, event):
994        pos = event.scenePos()
995        if self.item is None:
996            item = self.scene.item_at(pos, items.TextAnnotation)
997            if item is not None and not item.hasFocus():
998                self.editItem(item)
999                return False
1000
1001        return UserInteraction.mousePressEvent(self, event)
1002
1003    def editItem(self, item):
1004        annotation = self.scene.annotation_for_item(item)
1005        rect = item.geometry()  # TODO: map to scene if item has a parent.
1006        control = controlpoints.ControlPointRect(rect=rect)
1007        self.scene.addItem(control)
1008
1009        self.savedFramePen = item.framePen()
1010        self.savedRect = rect
1011
1012        control.rectEdited.connect(item.setGeometry)
1013        control.setFocusProxy(item)
1014
1015        item.setFramePen(QPen(Qt.DashDotLine))
1016        item.geometryChanged.connect(self.__on_textGeometryChanged)
1017
1018        self.item = item
1019
1020        self.annotation = annotation
1021        self.control = control
1022
1023    def commit(self):
1024        """Commit the current item geometry state to the document.
1025        """
1026        rect = self.item.geometry()
1027        if self.savedRect != rect:
1028            command = commands.SetAttrCommand(
1029                self.annotation, "rect",
1030                (rect.x(), rect.y(), rect.width(), rect.height()),
1031                name="Edit text geometry"
1032            )
1033            self.document.undoStack().push(command)
1034            self.savedRect = rect
1035
1036    def __on_editingFinished(self):
1037        self.commit()
1038        self.end()
1039
1040    def __on_rectEdited(self, rect):
1041        self.item.setGeometry(rect)
1042
1043    def __on_textGeometryChanged(self):
1044        if not self.control.isControlActive():
1045            rect = self.item.geometry()
1046            self.control.setRect(rect)
1047
1048    def cancel(self, reason=UserInteraction.OtherReason):
1049        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1050        if self.item is not None and self.savedRect is not None:
1051            self.item.setGeometry(self.savedRect)
1052
1053        UserInteraction.cancel(self, reason)
1054
1055    def end(self):
1056        if self.control is not None:
1057            self.scene.removeItem(self.control)
1058
1059        if self.item is not None:
1060            self.item.setFramePen(self.savedFramePen)
1061
1062        self.item = None
1063        self.annotation = None
1064        self.control = None
1065
1066        UserInteraction.end(self)
1067
1068
1069class ResizeArrowAnnotation(UserInteraction):
1070    def __init__(self, document, *args, **kwargs):
1071        UserInteraction.__init__(self, document, *args, **kwargs)
1072        self.item = None
1073        self.annotation = None
1074        self.control = None
1075        self.savedLine = None
1076
1077    def mousePressEvent(self, event):
1078        pos = event.scenePos()
1079        if self.item is None:
1080            item = self.scene.item_at(pos, items.ArrowAnnotation)
1081            if item is not None and not item.hasFocus():
1082                self.editItem(item)
1083                return False
1084
1085        return UserInteraction.mousePressEvent(self, event)
1086
1087    def editItem(self, item):
1088        annotation = self.scene.annotation_for_item(item)
1089        control = controlpoints.ControlPointLine()
1090        self.scene.addItem(control)
1091
1092        line = item.line()
1093        self.savedLine = line
1094
1095        p1, p2 = map(item.mapToScene, (line.p1(), line.p2()))
1096
1097        control.setLine(QLineF(p1, p2))
1098        control.setFocusProxy(item)
1099        control.lineEdited.connect(self.__on_lineEdited)
1100
1101        item.geometryChanged.connect(self.__on_lineGeometryChanged)
1102
1103        self.item = item
1104        self.annotation = annotation
1105        self.control = control
1106
1107    def commit(self):
1108        """Commit the current geometry of the item to the document.
1109
1110        .. note:: Does nothing if the actual geometry is not changed.
1111
1112        """
1113        line = self.control.line()
1114        p1, p2 = line.p1(), line.p2()
1115
1116        if self.item.line() != self.savedLine:
1117            command = commands.SetAttrCommand(
1118                self.annotation,
1119                "geometry",
1120                ((p1.x(), p1.y()), (p2.x(), p2.y())),
1121                name="Edit arrow geometry",
1122            )
1123            self.document.undoStack().push(command)
1124            self.savedLine = self.item.line()
1125
1126    def __on_editingFinished(self):
1127        self.commit()
1128        self.end()
1129
1130    def __on_lineEdited(self, line):
1131        p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2()))
1132        self.item.setLine(QLineF(p1, p2))
1133
1134    def __on_lineGeometryChanged(self):
1135        # Possible geometry change from out of our control, for instance
1136        # item move as a part of a selection group.
1137        if not self.control.isControlActive():
1138            line = self.item.line()
1139            p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2()))
1140            self.control.setLine(QLineF(p1, p2))
1141
1142    def cancel(self, reason=UserInteraction.OtherReason):
1143        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1144        if self.item is not None and self.savedLine is not None:
1145            self.item.setLine(self.savedLine)
1146
1147        UserInteraction.cancel(self, reason)
1148
1149    def end(self):
1150        if self.control is not None:
1151            self.scene.removeItem(self.control)
1152
1153        if self.item is not None:
1154            self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged)
1155
1156        self.control = None
1157        self.item = None
1158        self.annotation = None
1159
1160        UserInteraction.end(self)
Note: See TracBrowser for help on using the repository browser.