source: orange/Orange/OrangeCanvas/document/interactions.py @ 11203:7dc5309b3942

Revision 11203:7dc5309b3942, 33.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 17 months ago (diff)

Check for None in toGraphicsObjectIfPossible, fix event filter return values.

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 Qt, QObject, QSizeF, QPointF, QRectF, QLineF
12from PyQt4.QtCore import pyqtSignal as Signal
13
14from ..registry.qt import QtWidgetRegistry
15from .. import scheme
16from ..canvas import items
17from ..canvas.items import controlpoints
18from . import commands
19
20log = logging.getLogger(__name__)
21
22
23class UserInteraction(QObject):
24    # cancel reason flags
25    NoReason = 0  # No specified reason
26    UserCancelReason = 1  # User canceled the operation (e.g. pressing ESC)
27    InteractionOverrideReason = 3  # Another interaction was set
28    ErrorReason = 4  # An internal error occurred
29    OtherReason = 5
30
31    # Emitted when the interaction is set on the scene.
32    started = Signal()
33
34    # Emitted when the interaction finishes successfully.
35    finished = Signal()
36
37    # Emitted when the interaction ends (canceled or finished)
38    ended = Signal()
39
40    # Emitted when the interaction is canceled.
41    canceled = Signal([], [int])
42
43    def __init__(self, document, parent=None, deleteOnEnd=True):
44        QObject.__init__(self, parent)
45        self.document = document
46        self.scene = document.scene()
47        self.scheme = document.scheme()
48        self.deleteOnEnd = deleteOnEnd
49
50        self.cancelOnEsc = False
51
52        self.__finished = False
53        self.__canceled = False
54        self.__cancelReason = self.NoReason
55
56    def start(self):
57        """Start the interaction. This is called by the scene when
58        the interaction is installed.
59
60        Must be called from subclass implementations.
61
62        """
63        self.started.emit()
64
65    def end(self):
66        """Finish the interaction. Restore any leftover state in
67        this method.
68
69        .. note:: This gets called from the default `cancel` implementation.
70
71        """
72        self.__finished = True
73
74        if self.scene.user_interaction_handler is self:
75            self.scene.set_user_interaction_handler(None)
76
77        if self.__canceled:
78            self.canceled.emit()
79            self.canceled[int].emit(self.__cancelReason)
80        else:
81            self.finished.emit()
82
83        self.ended.emit()
84
85        if self.deleteOnEnd:
86            self.deleteLater()
87
88    def cancel(self, reason=OtherReason):
89        """Cancel the interaction for `reason`.
90        """
91
92        self.__canceled = True
93        self.__cancelReason = reason
94
95        self.end()
96
97    def isFinished(self):
98        """Has the interaction finished.
99        """
100        return self.__finished
101
102    def isCanceled(self):
103        """Was the interaction canceled.
104        """
105        return self.__canceled
106
107    def cancelReason(self):
108        """Return the reason the interaction was canceled.
109        """
110        return self.__cancelReason
111
112    def mousePressEvent(self, event):
113        return False
114
115    def mouseMoveEvent(self, event):
116        return False
117
118    def mouseReleaseEvent(self, event):
119        return False
120
121    def mouseDoubleClickEvent(self, event):
122        return False
123
124    def keyPressEvent(self, event):
125        if self.cancelOnEsc and event.key() == Qt.Key_Escape:
126            self.cancel(self.UserCancelReason)
127        return False
128
129    def keyReleaseEvent(self, event):
130        return False
131
132
133class NoPossibleLinksError(ValueError):
134    pass
135
136
137def reversed_arguments(func):
138    """Return a function with reversed argument order.
139    """
140    def wrapped(*args):
141        return func(*reversed(args))
142    return wrapped
143
144
145class NewLinkAction(UserInteraction):
146    """User drags a new link from an existing node anchor item to create
147    a connection between two existing nodes or to a new node if the release
148    is over an empty area, in which case a quick menu for new node selection
149    is presented to the user.
150
151    """
152    # direction of the drag
153    FROM_SOURCE = 1
154    FROM_SINK = 2
155
156    def __init__(self, document, *args, **kwargs):
157        UserInteraction.__init__(self, document, *args, **kwargs)
158        self.source_item = None
159        self.sink_item = None
160        self.from_item = None
161        self.direction = None
162
163        self.current_target_item = None
164        self.tmp_link_item = None
165        self.tmp_anchor_point = None
166        self.cursor_anchor_point = None
167
168    def remove_tmp_anchor(self):
169        """Remove a temp anchor point from the current target item.
170        """
171        if self.direction == self.FROM_SOURCE:
172            self.current_target_item.removeInputAnchor(self.tmp_anchor_point)
173        else:
174            self.current_target_item.removeOutputAnchor(self.tmp_anchor_point)
175        self.tmp_anchor_point = None
176
177    def create_tmp_anchor(self, item):
178        """Create a new tmp anchor at the item (`NodeItem`).
179        """
180        assert(self.tmp_anchor_point is None)
181        if self.direction == self.FROM_SOURCE:
182            self.tmp_anchor_point = item.newInputAnchor()
183        else:
184            self.tmp_anchor_point = item.newOutputAnchor()
185
186    def can_connect(self, target_item):
187        """Is the connection between `self.from_item` (item where the drag
188        started) and `target_item`.
189
190        """
191        node1 = self.scene.node_for_item(self.from_item)
192        node2 = self.scene.node_for_item(target_item)
193
194        if self.direction == self.FROM_SOURCE:
195            return bool(self.scheme.propose_links(node1, node2))
196        else:
197            return bool(self.scheme.propose_links(node2, node1))
198
199    def set_link_target_anchor(self, anchor):
200        """Set the temp line target anchor
201        """
202        if self.direction == self.FROM_SOURCE:
203            self.tmp_link_item.setSinkItem(None, anchor)
204        else:
205            self.tmp_link_item.setSourceItem(None, anchor)
206
207    def target_node_item_at(self, pos):
208        """Return a suitable NodeItem on which a link can be dropped.
209        """
210        # Test for a suitable NodeAnchorItem or NodeItem at pos.
211        if self.direction == self.FROM_SOURCE:
212            anchor_type = items.SinkAnchorItem
213        else:
214            anchor_type = items.SourceAnchorItem
215
216        item = self.scene.item_at(pos, (anchor_type, items.NodeItem))
217
218        if isinstance(item, anchor_type):
219            item = item.parentNodeItem()
220
221        return item
222
223    def mousePressEvent(self, event):
224        anchor_item = self.scene.item_at(event.scenePos(),
225                                         items.NodeAnchorItem)
226        if anchor_item and event.button() == Qt.LeftButton:
227            # Start a new link starting at item
228            self.from_item = anchor_item.parentNodeItem()
229            if isinstance(anchor_item, items.SourceAnchorItem):
230                self.direction = NewLinkAction.FROM_SOURCE
231                self.source_item = self.from_item
232            else:
233                self.direction = NewLinkAction.FROM_SINK
234                self.sink_item = self.from_item
235
236            event.accept()
237            return True
238        else:
239            # Whoever put us in charge did not know what he was doing.
240            self.cancel(self.ErrorReason)
241            return False
242
243    def mouseMoveEvent(self, event):
244        if not self.tmp_link_item:
245            # On first mouse move event create the temp link item and
246            # initialize it to follow the `cursor_anchor_point`.
247            self.tmp_link_item = items.LinkItem()
248            # An anchor under the cursor for the duration of this action.
249            self.cursor_anchor_point = items.AnchorPoint()
250            self.cursor_anchor_point.setPos(event.scenePos())
251
252            # Set the `fixed` end of the temp link (where the drag started).
253            if self.direction == self.FROM_SOURCE:
254                self.tmp_link_item.setSourceItem(self.source_item)
255            else:
256                self.tmp_link_item.setSinkItem(self.sink_item)
257
258            self.set_link_target_anchor(self.cursor_anchor_point)
259            self.scene.addItem(self.tmp_link_item)
260
261        # `NodeItem` at the cursor position
262        item = self.target_node_item_at(event.scenePos())
263
264        if self.current_target_item is not None and \
265                (item is None or item is not self.current_target_item):
266            # `current_target_item` is no longer under the mouse cursor
267            # (was replaced by another item or the the cursor was moved over
268            # an empty scene spot.
269            log.info("%r is no longer the target.", self.current_target_item)
270            self.remove_tmp_anchor()
271            self.current_target_item = None
272
273        if item is not None and item is not self.from_item:
274            # The mouse is over an node item (different from the starting node)
275            if self.current_target_item is item:
276                # Avoid reseting the points
277                pass
278            elif self.can_connect(item):
279                # Grab a new anchor
280                log.info("%r is the new target.", item)
281                self.create_tmp_anchor(item)
282                self.set_link_target_anchor(self.tmp_anchor_point)
283                self.current_target_item = item
284            else:
285                log.info("%r does not have compatible channels", item)
286                self.set_link_target_anchor(self.cursor_anchor_point)
287                # TODO: How to indicate that the connection is not possible?
288                #       The node's anchor could be drawn with a 'disabled'
289                #       palette
290        else:
291            self.set_link_target_anchor(self.cursor_anchor_point)
292
293        self.cursor_anchor_point.setPos(event.scenePos())
294
295        return True
296
297    def mouseReleaseEvent(self, event):
298        if self.tmp_link_item:
299            item = self.target_node_item_at(event.scenePos())
300            node = None
301            stack = self.document.undoStack()
302            stack.beginMacro("Add link")
303
304            if item:
305                # If the release was over a widget item
306                # then connect them
307                node = self.scene.node_for_item(item)
308            else:
309                # Release on an empty canvas part
310                # Show a quick menu popup for a new widget creation.
311                try:
312                    node = self.create_new(event)
313                except Exception:
314                    log.error("Failed to create a new node, ending.",
315                              exc_info=True)
316                    node = None
317
318                if node is not None:
319                    self.document.addNode(node)
320
321            if node is not None:
322                self.connect_existing(node)
323            else:
324                self.end()
325
326            stack.endMacro()
327        else:
328            self.end()
329            return False
330
331    def create_new(self, event):
332        """Create and return a new node with a QuickWidgetMenu.
333        """
334        pos = event.screenPos()
335        menu = self.document.quickMenu()
336        node = self.scene.node_for_item(self.from_item)
337        from_desc = node.description
338
339        def is_compatible(source, sink):
340            return any(scheme.compatible_channels(output, input) \
341                       for output in source.outputs \
342                       for input in sink.inputs)
343
344        if self.direction == self.FROM_SINK:
345            # Reverse the argument order.
346            is_compatible = reversed_arguments(is_compatible)
347
348        def filter(index):
349            desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
350            if desc.isValid():
351                desc = desc.toPyObject()
352                return is_compatible(from_desc, desc)
353            else:
354                return False
355
356        menu.setFilterFunc(filter)
357        try:
358            action = menu.exec_(pos)
359        finally:
360            menu.setFilterFunc(None)
361
362        if action:
363            item = action.property("item").toPyObject()
364            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
365            pos = event.scenePos()
366            node = scheme.SchemeNode(desc, position=(pos.x(), pos.y()))
367            return node
368
369    def connect_existing(self, node):
370        """Connect anchor_item to `node`.
371        """
372        if self.direction == self.FROM_SOURCE:
373            source_item = self.source_item
374            source_node = self.scene.node_for_item(source_item)
375            sink_node = node
376        else:
377            source_node = node
378            sink_item = self.sink_item
379            sink_node = self.scene.node_for_item(sink_item)
380
381        try:
382            possible = self.scheme.propose_links(source_node, sink_node)
383
384            log.debug("proposed (weighted) links: %r",
385                      [(s1.name, s2.name, w) for s1, s2, w in possible])
386
387            if not possible:
388                raise NoPossibleLinksError
389
390            source, sink, w = possible[0]
391            links_to_add = [(source, sink)]
392
393            show_link_dialog = False
394
395            # Ambiguous new link request.
396            if len(possible) >= 2:
397                # Check for possible ties in the proposed link weights
398                _, _, w2 = possible[1]
399                if w == w2:
400                    show_link_dialog = True
401
402                # Check for destructive action (i.e. would the new link
403                # replace a previous link)
404                if sink.single and self.scheme.find_links(sink_node=sink_node,
405                                                          sink_channel=sink):
406                    show_link_dialog = True
407
408                if show_link_dialog:
409                    links_action = EditNodeLinksAction(
410                                    self.document, source_node, sink_node)
411                    try:
412                        links_action.edit_links()
413                    except Exception:
414                        log.error("'EditNodeLinksAction' failed",
415                                  exc_info=True)
416                        raise
417                    # EditNodeLinksAction already commits the links on accepted
418                    links_to_add = []
419
420            for source, sink in links_to_add:
421                if sink.single:
422                    # Remove an existing link to the sink channel if present.
423                    existing_link = self.scheme.find_links(
424                        sink_node=sink_node, sink_channel=sink
425                    )
426
427                    if existing_link:
428                        self.document.removeLink(existing_link[0])
429
430                # Check if the new link is a duplicate of an existing link
431                duplicate = self.scheme.find_links(
432                    source_node, source, sink_node, sink
433                )
434
435                if duplicate:
436                    # Do nothing.
437                    continue
438
439                # Remove temp items before creating a new link
440                self.cleanup()
441
442                link = scheme.SchemeLink(source_node, source, sink_node, sink)
443                self.document.addLink(link)
444
445        except scheme.IncompatibleChannelTypeError:
446            log.info("Cannot connect: invalid channel types.")
447            self.cancel()
448        except scheme.SchemeTopologyError:
449            log.info("Cannot connect: connection creates a cycle.")
450            self.cancel()
451        except NoPossibleLinksError:
452            log.info("Cannot connect: no possible links.")
453            self.cancel()
454        except Exception:
455            log.error("An error occurred during the creation of a new link.",
456                      exc_info=True)
457            self.cancel()
458
459        if not self.isFinished():
460            self.end()
461
462    def end(self):
463        self.cleanup()
464        UserInteraction.end(self)
465
466    def cancel(self, reason=UserInteraction.OtherReason):
467        self.cleanup()
468        UserInteraction.cancel(self, reason)
469
470    def cleanup(self):
471        """Cleanup all temp items in the scene that are left.
472        """
473        if self.tmp_link_item:
474            self.tmp_link_item.setSinkItem(None)
475            self.tmp_link_item.setSourceItem(None)
476
477            if self.tmp_link_item.scene():
478                self.scene.removeItem(self.tmp_link_item)
479
480            self.tmp_link_item = None
481
482        if self.current_target_item:
483            self.remove_tmp_anchor()
484            self.current_target_item = None
485
486        if self.cursor_anchor_point and self.cursor_anchor_point.scene():
487            self.scene.removeItem(self.cursor_anchor_point)
488            self.cursor_anchor_point = None
489
490
491class NewNodeAction(UserInteraction):
492    """Present the user with a quick menu for node selection and
493    create the selected node.
494
495    """
496
497    def mousePressEvent(self, event):
498        if event.button() == Qt.RightButton:
499            self.create_new(event.screenPos())
500            self.end()
501
502    def create_new(self, pos):
503        """Create a new widget with a QuickWidgetMenu at `pos`
504        (in screen coordinates).
505
506        """
507        menu = self.document.quickMenu()
508        menu.setFilterFunc(None)
509
510        action = menu.exec_(pos)
511        if action:
512            item = action.property("item").toPyObject()
513            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
514            # Get the scene position
515            view = self.document.view()
516            pos = view.mapToScene(view.mapFromGlobal(pos))
517            node = scheme.SchemeNode(desc, position=(pos.x(), pos.y()))
518            self.document.addNode(node)
519            return node
520
521
522class RectangleSelectionAction(UserInteraction):
523    """Select items in the scene using a Rectangle selection
524    """
525    def __init__(self, document, *args, **kwargs):
526        UserInteraction.__init__(self, document, *args, **kwargs)
527        self.initial_selection = None
528        self.selection_rect = None
529
530    def mousePressEvent(self, event):
531        pos = event.scenePos()
532        any_item = self.scene.item_at(pos)
533        if not any_item and event.button() & Qt.LeftButton:
534            self.selection_rect = QRectF(pos, QSizeF(0, 0))
535            self.rect_item = QGraphicsRectItem(
536                self.selection_rect.normalized()
537            )
538
539            self.rect_item.setPen(
540                QPen(QBrush(QColor(51, 153, 255, 192)),
541                     0.4, Qt.SolidLine, Qt.RoundCap)
542            )
543
544            self.rect_item.setBrush(
545                QBrush(QColor(168, 202, 236, 192))
546            )
547
548            self.rect_item.setZValue(-100)
549
550            # Clear the focus if necessary.
551            if not self.scene.stickyFocus():
552                self.scene.clearFocus()
553            event.accept()
554            return True
555        else:
556            self.cancel(self.ErrorReason)
557            return False
558
559    def mouseMoveEvent(self, event):
560        if not self.rect_item.scene():
561            self.scene.addItem(self.rect_item)
562        self.update_selection(event)
563        return True
564
565    def mouseReleaseEvent(self, event):
566        self.update_selection(event)
567        self.end()
568        return True
569
570    def update_selection(self, event):
571        if self.initial_selection is None:
572            self.initial_selection = self.scene.selectedItems()
573
574        pos = event.scenePos()
575        self.selection_rect = QRectF(self.selection_rect.topLeft(), pos)
576        self.rect_item.setRect(self.selection_rect.normalized())
577
578        selected = self.scene.items(self.selection_rect.normalized(),
579                                    Qt.IntersectsItemShape,
580                                    Qt.AscendingOrder)
581
582        selected = [item for item in selected if \
583                    item.flags() & Qt.ItemIsSelectable]
584        if event.modifiers() & Qt.ControlModifier:
585            for item in selected:
586                item.setSelected(item not in self.initial_selection)
587        else:
588            for item in self.initial_selection:
589                item.setSelected(False)
590            for item in selected:
591                item.setSelected(True)
592
593    def end(self):
594        self.initial_selection = None
595        self.rect_item.hide()
596        if self.rect_item.scene() is not None:
597            self.scene.removeItem(self.rect_item)
598        UserInteraction.end(self)
599
600
601class EditNodeLinksAction(UserInteraction):
602    def __init__(self, document, source_node, sink_node, *args, **kwargs):
603        UserInteraction.__init__(self, document, *args, **kwargs)
604        self.source_node = source_node
605        self.sink_node = sink_node
606
607    def edit_links(self):
608        from ..canvas.editlinksdialog import EditLinksDialog
609
610        log.info("Constructing a Link Editor dialog.")
611
612        parent = self.scene.views()[0]
613        dlg = EditLinksDialog(parent)
614
615        links = self.scheme.find_links(source_node=self.source_node,
616                                       sink_node=self.sink_node)
617        existing_links = [(link.source_channel, link.sink_channel)
618                          for link in links]
619
620        dlg.setNodes(self.source_node, self.sink_node)
621        dlg.setLinks(existing_links)
622
623        log.info("Executing a Link Editor Dialog.")
624        rval = dlg.exec_()
625
626        if rval == EditLinksDialog.Accepted:
627            links = dlg.links()
628
629            links_to_add = set(links) - set(existing_links)
630            links_to_remove = set(existing_links) - set(links)
631
632            stack = self.document.undoStack()
633            stack.beginMacro("Edit Links")
634
635            for source_channel, sink_channel in links_to_remove:
636                links = self.scheme.find_links(source_node=self.source_node,
637                                               source_channel=source_channel,
638                                               sink_node=self.sink_node,
639                                               sink_channel=sink_channel)
640
641                self.document.removeLink(links[0])
642
643            for source_channel, sink_channel in links_to_add:
644                link = scheme.SchemeLink(self.source_node, source_channel,
645                                         self.sink_node, sink_channel)
646
647                self.document.addLink(link)
648            stack.endMacro()
649
650
651def point_to_tuple(point):
652    return point.x(), point.y()
653
654
655class NewArrowAnnotation(UserInteraction):
656    """Create a new arrow annotation.
657    """
658    def __init__(self, document, *args, **kwargs):
659        UserInteraction.__init__(self, document, *args, **kwargs)
660        self.down_pos = None
661        self.arrow_item = None
662        self.annotation = None
663        self.color = "red"
664
665    def start(self):
666        self.document.view().setCursor(Qt.CrossCursor)
667        UserInteraction.start(self)
668
669    def setColor(self, color):
670        self.color = color
671
672    def mousePressEvent(self, event):
673        if event.button() == Qt.LeftButton:
674            self.down_pos = event.scenePos()
675            event.accept()
676            return True
677
678    def mouseMoveEvent(self, event):
679        if event.buttons() & Qt.LeftButton:
680            if self.arrow_item is None and \
681                    (self.down_pos - event.scenePos()).manhattanLength() > \
682                    QApplication.instance().startDragDistance():
683
684                annot = scheme.SchemeArrowAnnotation(
685                    point_to_tuple(self.down_pos),
686                    point_to_tuple(event.scenePos())
687                )
688                annot.set_color(self.color)
689                item = self.scene.add_annotation(annot)
690
691                self.arrow_item = item
692                self.annotation = annot
693
694            if self.arrow_item is not None:
695                p1, p2 = map(self.arrow_item.mapFromScene,
696                             (self.down_pos, event.scenePos()))
697                self.arrow_item.setLine(QLineF(p1, p2))
698                self.arrow_item.adjustGeometry()
699
700            event.accept()
701            return True
702
703    def mouseReleaseEvent(self, event):
704        if event.button() == Qt.LeftButton:
705            if self.arrow_item is not None:
706                p1, p2 = self.down_pos, event.scenePos()
707
708                # Commit the annotation to the scheme
709                self.annotation.set_line(point_to_tuple(p1),
710                                         point_to_tuple(p2))
711
712                self.document.addAnnotation(self.annotation)
713
714                p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2))
715                self.arrow_item.setLine(QLineF(p1, p2))
716                self.arrow_item.adjustGeometry()
717
718            self.end()
719            return True
720
721    def end(self):
722        self.down_pos = None
723        self.arrow_item = None
724        self.annotation = None
725        self.document.view().setCursor(Qt.ArrowCursor)
726        UserInteraction.end(self)
727
728
729def rect_to_tuple(rect):
730    return rect.x(), rect.y(), rect.width(), rect.height()
731
732
733class NewTextAnnotation(UserInteraction):
734    def __init__(self, document, *args, **kwargs):
735        UserInteraction.__init__(self, document, *args, **kwargs)
736        self.down_pos = None
737        self.annotation_item = None
738        self.annotation = None
739        self.control = None
740        self.font = document.font()
741
742    def setFont(self, font):
743        self.font = font
744
745    def start(self):
746        self.document.view().setCursor(Qt.CrossCursor)
747        UserInteraction.start(self)
748
749    def createNewAnnotation(self, rect):
750        """Create a new TextAnnotation at with `rect` as the geometry.
751        """
752        annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
753        font = {"family": unicode(self.font.family()),
754                "size": self.font.pointSize()}
755        annot.set_font(font)
756
757        item = self.scene.add_annotation(annot)
758        item.setTextInteractionFlags(Qt.TextEditorInteraction)
759        item.setFramePen(QPen(Qt.DashLine))
760
761        self.annotation_item = item
762        self.annotation = annot
763        self.control = controlpoints.ControlPointRect()
764        self.control.rectChanged.connect(
765            self.annotation_item.setGeometry
766        )
767        self.scene.addItem(self.control)
768
769    def mousePressEvent(self, event):
770        if event.button() == Qt.LeftButton:
771            self.down_pos = event.scenePos()
772            return True
773
774    def mouseMoveEvent(self, event):
775        if event.buttons() & Qt.LeftButton:
776            if self.annotation_item is None and \
777                    (self.down_pos - event.scenePos()).manhattanLength() > \
778                    QApplication.instance().startDragDistance():
779                rect = QRectF(self.down_pos, event.scenePos()).normalized()
780                self.createNewAnnotation(rect)
781
782            if self.annotation_item is not None:
783                rect = QRectF(self.down_pos, event.scenePos()).normalized()
784                self.control.setRect(rect)
785
786            return True
787
788    def mouseReleaseEvent(self, event):
789        if event.button() == Qt.LeftButton:
790            if self.annotation_item is None:
791                self.createNewAnnotation(QRectF(event.scenePos(),
792                                                event.scenePos()))
793                rect = self.defaultTextGeometry(event.scenePos())
794
795            else:
796                rect = QRectF(self.down_pos, event.scenePos()).normalized()
797
798            # Commit the annotation to the scheme.
799            self.annotation.rect = rect_to_tuple(rect)
800
801            self.document.addAnnotation(self.annotation)
802
803            self.annotation_item.setGeometry(rect)
804
805            self.control.rectChanged.disconnect(
806                self.annotation_item.setGeometry
807            )
808            self.control.hide()
809
810            # Move the focus to the editor.
811            self.annotation_item.setFramePen(QPen(Qt.NoPen))
812            self.annotation_item.setFocus(Qt.OtherFocusReason)
813            self.annotation_item.startEdit()
814
815            self.end()
816
817    def defaultTextGeometry(self, point):
818        """Return the default text geometry. Used in case the user
819        single clicked in the scene.
820
821        """
822        font = self.annotation_item.font()
823        metrics = QFontMetrics(font)
824        spacing = metrics.lineSpacing()
825        margin = self.annotation_item.document().documentMargin()
826
827        rect = QRectF(QPointF(point.x(), point.y() - spacing - margin),
828                      QSizeF(150, spacing + 2 * margin))
829        return rect
830
831    def end(self):
832        if self.control is not None:
833            self.scene.removeItem(self.control)
834
835        self.control = None
836        self.down_pos = None
837        self.annotation_item = None
838        self.annotation = None
839        self.document.view().setCursor(Qt.ArrowCursor)
840        UserInteraction.end(self)
841
842
843class ResizeTextAnnotation(UserInteraction):
844    def __init__(self, document, *args, **kwargs):
845        UserInteraction.__init__(self, document, *args, **kwargs)
846        self.item = None
847        self.annotation = None
848        self.control = None
849        self.savedFramePen = None
850        self.savedRect = None
851
852    def mousePressEvent(self, event):
853        pos = event.scenePos()
854        if self.item is None:
855            item = self.scene.item_at(pos, items.TextAnnotation)
856            if item is not None and not item.hasFocus():
857                self.editItem(item)
858                return False
859
860        return UserInteraction.mousePressEvent(self, event)
861
862    def editItem(self, item):
863        annotation = self.scene.annotation_for_item(item)
864        rect = item.geometry()  # TODO: map to scene if item has a parent.
865        control = controlpoints.ControlPointRect(rect=rect)
866        self.scene.addItem(control)
867
868        self.savedFramePen = item.framePen()
869        self.savedRect = rect
870
871        control.rectEdited.connect(item.setGeometry)
872        control.setFocusProxy(item)
873
874        item.setFramePen(QPen(Qt.DashDotLine))
875        item.geometryChanged.connect(self.__on_textGeometryChanged)
876
877        self.item = item
878
879        self.annotation = annotation
880        self.control = control
881
882    def commit(self):
883        """Commit the current item geometry state to the document.
884        """
885        rect = self.item.geometry()
886        if self.savedRect != rect:
887            command = commands.SetAttrCommand(
888                self.annotation, "rect",
889                (rect.x(), rect.y(), rect.width(), rect.height()),
890                name="Edit text geometry"
891            )
892            self.document.undoStack().push(command)
893            self.savedRect = rect
894
895    def __on_editingFinished(self):
896        self.commit()
897        self.end()
898
899    def __on_rectEdited(self, rect):
900        self.item.setGeometry(rect)
901
902    def __on_textGeometryChanged(self):
903        if not self.control.isControlActive():
904            rect = self.item.geometry()
905            self.control.setRect(rect)
906
907    def cancel(self, reason=UserInteraction.OtherReason):
908        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
909        if self.item is not None and self.savedRect is not None:
910            self.item.setGeometry(self.savedRect)
911
912        UserInteraction.cancel(self, reason)
913
914    def end(self):
915        if self.control is not None:
916            self.scene.removeItem(self.control)
917
918        if self.item is not None:
919            self.item.setFramePen(self.savedFramePen)
920
921        self.item = None
922        self.annotation = None
923        self.control = None
924
925        UserInteraction.end(self)
926
927
928class ResizeArrowAnnotation(UserInteraction):
929    def __init__(self, document, *args, **kwargs):
930        UserInteraction.__init__(self, document, *args, **kwargs)
931        self.item = None
932        self.annotation = None
933        self.control = None
934        self.savedLine = None
935
936    def mousePressEvent(self, event):
937        pos = event.scenePos()
938        if self.item is None:
939            item = self.scene.item_at(pos, items.ArrowAnnotation)
940            if item is not None and not item.hasFocus():
941                self.editItem(item)
942                return False
943
944        return UserInteraction.mousePressEvent(self, event)
945
946    def editItem(self, item):
947        annotation = self.scene.annotation_for_item(item)
948        control = controlpoints.ControlPointLine()
949        self.scene.addItem(control)
950
951        line = item.line()
952        self.savedLine = line
953
954        p1, p2 = map(item.mapToScene, (line.p1(), line.p2()))
955
956        control.setLine(QLineF(p1, p2))
957        control.setFocusProxy(item)
958        control.lineEdited.connect(self.__on_lineEdited)
959
960        item.geometryChanged.connect(self.__on_lineGeometryChanged)
961
962        self.item = item
963        self.annotation = annotation
964        self.control = control
965
966    def commit(self):
967        """Commit the current geometry of the item to the document.
968
969        .. note:: Does nothing if the actual geometry is not changed.
970
971        """
972        line = self.control.line()
973        p1, p2 = line.p1(), line.p2()
974
975        if self.item.line() != self.savedLine:
976            command = commands.SetAttrCommand(
977                self.annotation,
978                "geometry",
979                ((p1.x(), p1.y()), (p2.x(), p2.y())),
980                name="Edit arrow geometry",
981            )
982            self.document.undoStack().push(command)
983            self.savedLine = self.item.line()
984
985    def __on_editingFinished(self):
986        self.commit()
987        self.end()
988
989    def __on_lineEdited(self, line):
990        p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2()))
991        self.item.setLine(QLineF(p1, p2))
992        self.item.adjustGeometry()
993
994    def __on_lineGeometryChanged(self):
995        # Possible geometry change from out of our control, for instance
996        # item move as a part of a selection group.
997        if not self.control.isControlActive():
998            line = self.item.line()
999            p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2()))
1000            self.control.setLine(QLineF(p1, p2))
1001
1002    def cancel(self, reason=UserInteraction.OtherReason):
1003        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1004        if self.item is not None and self.savedLine is not None:
1005            self.item.setLine(self.savedLine)
1006
1007        UserInteraction.cancel(self, reason)
1008
1009    def end(self):
1010        if self.control is not None:
1011            self.scene.removeItem(self.control)
1012
1013        if self.item is not None:
1014            self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged)
1015
1016        self.control = None
1017        self.item = None
1018        self.annotation = None
1019
1020        UserInteraction.end(self)
Note: See TracBrowser for help on using the repository browser.