source: orange/Orange/OrangeCanvas/document/interactions.py @ 11272:0bfd3a5b6d28

Revision 11272:0bfd3a5b6d28, 36.7 KB checked in by Ales Erjavec <ales.erjavec@…>, 15 months ago (diff)

Added scheme document interaction help tips.

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            for source_channel, sink_channel in links_to_remove:
699                links = self.scheme.find_links(source_node=self.source_node,
700                                               source_channel=source_channel,
701                                               sink_node=self.sink_node,
702                                               sink_channel=sink_channel)
703
704                self.document.removeLink(links[0])
705
706            for source_channel, sink_channel in links_to_add:
707                link = scheme.SchemeLink(self.source_node, source_channel,
708                                         self.sink_node, sink_channel)
709
710                self.document.addLink(link)
711            stack.endMacro()
712
713
714def point_to_tuple(point):
715    return point.x(), point.y()
716
717
718class NewArrowAnnotation(UserInteraction):
719    """Create a new arrow annotation.
720    """
721    def __init__(self, document, *args, **kwargs):
722        UserInteraction.__init__(self, document, *args, **kwargs)
723        self.down_pos = None
724        self.arrow_item = None
725        self.annotation = None
726        self.color = "red"
727
728    def start(self):
729        self.document.view().setCursor(Qt.CrossCursor)
730
731        helpevent = QuickHelpTipEvent(
732            self.tr("Click and drag to create a new arrow"),
733            self.tr('<h3>New arrow annotation</h3>'
734                    '<p>Click and drag to create a new arrow annotation</p>'
735#                    '<a href="help://orange-canvas/arrow-annotations>'
736#                    'More ...</a>'
737                    )
738        )
739        QCoreApplication.postEvent(self.document, helpevent)
740
741        UserInteraction.start(self)
742
743    def setColor(self, color):
744        self.color = color
745
746    def mousePressEvent(self, event):
747        if event.button() == Qt.LeftButton:
748            self.down_pos = event.scenePos()
749            event.accept()
750            return True
751
752    def mouseMoveEvent(self, event):
753        if event.buttons() & Qt.LeftButton:
754            if self.arrow_item is None and \
755                    (self.down_pos - event.scenePos()).manhattanLength() > \
756                    QApplication.instance().startDragDistance():
757
758                annot = scheme.SchemeArrowAnnotation(
759                    point_to_tuple(self.down_pos),
760                    point_to_tuple(event.scenePos())
761                )
762                annot.set_color(self.color)
763                item = self.scene.add_annotation(annot)
764
765                self.arrow_item = item
766                self.annotation = annot
767
768            if self.arrow_item is not None:
769                p1, p2 = map(self.arrow_item.mapFromScene,
770                             (self.down_pos, event.scenePos()))
771                self.arrow_item.setLine(QLineF(p1, p2))
772                self.arrow_item.adjustGeometry()
773
774            event.accept()
775            return True
776
777    def mouseReleaseEvent(self, event):
778        if event.button() == Qt.LeftButton:
779            if self.arrow_item is not None:
780                p1, p2 = self.down_pos, event.scenePos()
781
782                # Commit the annotation to the scheme
783                self.annotation.set_line(point_to_tuple(p1),
784                                         point_to_tuple(p2))
785
786                self.document.addAnnotation(self.annotation)
787
788                p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2))
789                self.arrow_item.setLine(QLineF(p1, p2))
790                self.arrow_item.adjustGeometry()
791
792            self.end()
793            return True
794
795    def end(self):
796        self.down_pos = None
797        self.arrow_item = None
798        self.annotation = None
799        self.document.view().setCursor(Qt.ArrowCursor)
800
801        # Clear the help tip
802        helpevent = QuickHelpTipEvent("", "")
803        QCoreApplication.postEvent(self.document, helpevent)
804
805        UserInteraction.end(self)
806
807
808def rect_to_tuple(rect):
809    return rect.x(), rect.y(), rect.width(), rect.height()
810
811
812class NewTextAnnotation(UserInteraction):
813    def __init__(self, document, *args, **kwargs):
814        UserInteraction.__init__(self, document, *args, **kwargs)
815        self.down_pos = None
816        self.annotation_item = None
817        self.annotation = None
818        self.control = None
819        self.font = document.font()
820
821    def setFont(self, font):
822        self.font = font
823
824    def start(self):
825        self.document.view().setCursor(Qt.CrossCursor)
826
827        helpevent = QuickHelpTipEvent(
828            self.tr("Click to create a new text annotation"),
829            self.tr('<h3>New text annotation</h3>'
830                    '<p>Click (and drag to resize) on the canvas to create '
831                    'a new text annotation item.</p>'
832#                    '<a href="help://orange-canvas/text-annotations">'
833#                    'More ...</a>'
834                    )
835        )
836        QCoreApplication.postEvent(self.document, helpevent)
837
838        UserInteraction.start(self)
839
840    def createNewAnnotation(self, rect):
841        """Create a new TextAnnotation at with `rect` as the geometry.
842        """
843        annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
844        font = {"family": unicode(self.font.family()),
845                "size": self.font.pointSize()}
846        annot.set_font(font)
847
848        item = self.scene.add_annotation(annot)
849        item.setTextInteractionFlags(Qt.TextEditorInteraction)
850        item.setFramePen(QPen(Qt.DashLine))
851
852        self.annotation_item = item
853        self.annotation = annot
854        self.control = controlpoints.ControlPointRect()
855        self.control.rectChanged.connect(
856            self.annotation_item.setGeometry
857        )
858        self.scene.addItem(self.control)
859
860    def mousePressEvent(self, event):
861        if event.button() == Qt.LeftButton:
862            self.down_pos = event.scenePos()
863            return True
864
865    def mouseMoveEvent(self, event):
866        if event.buttons() & Qt.LeftButton:
867            if self.annotation_item is None and \
868                    (self.down_pos - event.scenePos()).manhattanLength() > \
869                    QApplication.instance().startDragDistance():
870                rect = QRectF(self.down_pos, event.scenePos()).normalized()
871                self.createNewAnnotation(rect)
872
873            if self.annotation_item is not None:
874                rect = QRectF(self.down_pos, event.scenePos()).normalized()
875                self.control.setRect(rect)
876
877            return True
878
879    def mouseReleaseEvent(self, event):
880        if event.button() == Qt.LeftButton:
881            if self.annotation_item is None:
882                self.createNewAnnotation(QRectF(event.scenePos(),
883                                                event.scenePos()))
884                rect = self.defaultTextGeometry(event.scenePos())
885
886            else:
887                rect = QRectF(self.down_pos, event.scenePos()).normalized()
888
889            # Commit the annotation to the scheme.
890            self.annotation.rect = rect_to_tuple(rect)
891
892            self.document.addAnnotation(self.annotation)
893
894            self.annotation_item.setGeometry(rect)
895
896            self.control.rectChanged.disconnect(
897                self.annotation_item.setGeometry
898            )
899            self.control.hide()
900
901            # Move the focus to the editor.
902            self.annotation_item.setFramePen(QPen(Qt.NoPen))
903            self.annotation_item.setFocus(Qt.OtherFocusReason)
904            self.annotation_item.startEdit()
905
906            self.end()
907
908    def defaultTextGeometry(self, point):
909        """Return the default text geometry. Used in case the user
910        single clicked in the scene.
911
912        """
913        font = self.annotation_item.font()
914        metrics = QFontMetrics(font)
915        spacing = metrics.lineSpacing()
916        margin = self.annotation_item.document().documentMargin()
917
918        rect = QRectF(QPointF(point.x(), point.y() - spacing - margin),
919                      QSizeF(150, spacing + 2 * margin))
920        return rect
921
922    def end(self):
923        if self.control is not None:
924            self.scene.removeItem(self.control)
925
926        self.control = None
927        self.down_pos = None
928        self.annotation_item = None
929        self.annotation = None
930        self.document.view().setCursor(Qt.ArrowCursor)
931
932        # Clear the help tip
933        helpevent = QuickHelpTipEvent("", "")
934        QCoreApplication.postEvent(self.document, helpevent)
935
936        UserInteraction.end(self)
937
938
939class ResizeTextAnnotation(UserInteraction):
940    def __init__(self, document, *args, **kwargs):
941        UserInteraction.__init__(self, document, *args, **kwargs)
942        self.item = None
943        self.annotation = None
944        self.control = None
945        self.savedFramePen = None
946        self.savedRect = None
947
948    def mousePressEvent(self, event):
949        pos = event.scenePos()
950        if self.item is None:
951            item = self.scene.item_at(pos, items.TextAnnotation)
952            if item is not None and not item.hasFocus():
953                self.editItem(item)
954                return False
955
956        return UserInteraction.mousePressEvent(self, event)
957
958    def editItem(self, item):
959        annotation = self.scene.annotation_for_item(item)
960        rect = item.geometry()  # TODO: map to scene if item has a parent.
961        control = controlpoints.ControlPointRect(rect=rect)
962        self.scene.addItem(control)
963
964        self.savedFramePen = item.framePen()
965        self.savedRect = rect
966
967        control.rectEdited.connect(item.setGeometry)
968        control.setFocusProxy(item)
969
970        item.setFramePen(QPen(Qt.DashDotLine))
971        item.geometryChanged.connect(self.__on_textGeometryChanged)
972
973        self.item = item
974
975        self.annotation = annotation
976        self.control = control
977
978    def commit(self):
979        """Commit the current item geometry state to the document.
980        """
981        rect = self.item.geometry()
982        if self.savedRect != rect:
983            command = commands.SetAttrCommand(
984                self.annotation, "rect",
985                (rect.x(), rect.y(), rect.width(), rect.height()),
986                name="Edit text geometry"
987            )
988            self.document.undoStack().push(command)
989            self.savedRect = rect
990
991    def __on_editingFinished(self):
992        self.commit()
993        self.end()
994
995    def __on_rectEdited(self, rect):
996        self.item.setGeometry(rect)
997
998    def __on_textGeometryChanged(self):
999        if not self.control.isControlActive():
1000            rect = self.item.geometry()
1001            self.control.setRect(rect)
1002
1003    def cancel(self, reason=UserInteraction.OtherReason):
1004        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1005        if self.item is not None and self.savedRect is not None:
1006            self.item.setGeometry(self.savedRect)
1007
1008        UserInteraction.cancel(self, reason)
1009
1010    def end(self):
1011        if self.control is not None:
1012            self.scene.removeItem(self.control)
1013
1014        if self.item is not None:
1015            self.item.setFramePen(self.savedFramePen)
1016
1017        self.item = None
1018        self.annotation = None
1019        self.control = None
1020
1021        UserInteraction.end(self)
1022
1023
1024class ResizeArrowAnnotation(UserInteraction):
1025    def __init__(self, document, *args, **kwargs):
1026        UserInteraction.__init__(self, document, *args, **kwargs)
1027        self.item = None
1028        self.annotation = None
1029        self.control = None
1030        self.savedLine = None
1031
1032    def mousePressEvent(self, event):
1033        pos = event.scenePos()
1034        if self.item is None:
1035            item = self.scene.item_at(pos, items.ArrowAnnotation)
1036            if item is not None and not item.hasFocus():
1037                self.editItem(item)
1038                return False
1039
1040        return UserInteraction.mousePressEvent(self, event)
1041
1042    def editItem(self, item):
1043        annotation = self.scene.annotation_for_item(item)
1044        control = controlpoints.ControlPointLine()
1045        self.scene.addItem(control)
1046
1047        line = item.line()
1048        self.savedLine = line
1049
1050        p1, p2 = map(item.mapToScene, (line.p1(), line.p2()))
1051
1052        control.setLine(QLineF(p1, p2))
1053        control.setFocusProxy(item)
1054        control.lineEdited.connect(self.__on_lineEdited)
1055
1056        item.geometryChanged.connect(self.__on_lineGeometryChanged)
1057
1058        self.item = item
1059        self.annotation = annotation
1060        self.control = control
1061
1062    def commit(self):
1063        """Commit the current geometry of the item to the document.
1064
1065        .. note:: Does nothing if the actual geometry is not changed.
1066
1067        """
1068        line = self.control.line()
1069        p1, p2 = line.p1(), line.p2()
1070
1071        if self.item.line() != self.savedLine:
1072            command = commands.SetAttrCommand(
1073                self.annotation,
1074                "geometry",
1075                ((p1.x(), p1.y()), (p2.x(), p2.y())),
1076                name="Edit arrow geometry",
1077            )
1078            self.document.undoStack().push(command)
1079            self.savedLine = self.item.line()
1080
1081    def __on_editingFinished(self):
1082        self.commit()
1083        self.end()
1084
1085    def __on_lineEdited(self, line):
1086        p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2()))
1087        self.item.setLine(QLineF(p1, p2))
1088        self.item.adjustGeometry()
1089
1090    def __on_lineGeometryChanged(self):
1091        # Possible geometry change from out of our control, for instance
1092        # item move as a part of a selection group.
1093        if not self.control.isControlActive():
1094            line = self.item.line()
1095            p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2()))
1096            self.control.setLine(QLineF(p1, p2))
1097
1098    def cancel(self, reason=UserInteraction.OtherReason):
1099        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1100        if self.item is not None and self.savedLine is not None:
1101            self.item.setLine(self.savedLine)
1102
1103        UserInteraction.cancel(self, reason)
1104
1105    def end(self):
1106        if self.control is not None:
1107            self.scene.removeItem(self.control)
1108
1109        if self.item is not None:
1110            self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged)
1111
1112        self.control = None
1113        self.item = None
1114        self.annotation = None
1115
1116        UserInteraction.end(self)
Note: See TracBrowser for help on using the repository browser.