source: orange/Orange/OrangeCanvas/document/interactions.py @ 11279:e788ced9f4b2

Revision 11279:e788ced9f4b2, 38.5 KB checked in by Ales Erjavec <ales.erjavec@…>, 15 months ago (diff)

Enumerating new node titles as they are created.

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