source: orange/Orange/OrangeCanvas/document/schemeedit.py @ 11264:a5c2117750d3

Revision 11264:a5c2117750d3, 46.0 KB checked in by Ales Erjavec <ales.erjavec@…>, 15 months ago (diff)

Providing help for widgets.

Line 
1"""
2Scheme Edit widget.
3
4"""
5
6import sys
7import logging
8
9from operator import attrgetter
10from contextlib import nested
11from urllib import urlencode
12
13from PyQt4.QtGui import (
14    QWidget, QVBoxLayout, QInputDialog, QMenu, QAction, QActionGroup,
15    QKeySequence, QUndoStack, QGraphicsItem, QGraphicsObject,
16    QGraphicsTextItem, QCursor, QFont, QPainter, QPixmap, QColor,
17    QIcon, QDesktopServices, QWhatsThisClickedEvent
18)
19
20from PyQt4.QtCore import (
21    Qt, QObject, QEvent, QSignalMapper, QRectF, QUrl, QCoreApplication
22)
23
24from PyQt4.QtCore import pyqtProperty as Property, pyqtSignal as Signal
25
26from ..registry.qt import whats_this_helper
27from ..gui.quickhelp import QuickHelpTipEvent
28from ..gui.utils import message_information, disabled
29from ..scheme import scheme
30from ..canvas.scene import CanvasScene
31from ..canvas.view import CanvasView
32from ..canvas import items
33from . import interactions
34from . import commands
35from . import quickmenu
36
37
38log = logging.getLogger(__name__)
39
40
41# TODO: Should this be moved to CanvasScene?
42class GraphicsSceneFocusEventListener(QGraphicsObject):
43
44    itemFocusedIn = Signal(QGraphicsItem)
45    itemFocusedOut = Signal(QGraphicsItem)
46
47    def __init__(self, parent=None):
48        QGraphicsObject.__init__(self, parent)
49        self.setFlag(QGraphicsItem.ItemHasNoContents)
50
51    def sceneEventFilter(self, obj, event):
52        if event.type() == QEvent.FocusIn and \
53                obj.flags() & QGraphicsItem.ItemIsFocusable:
54            obj.focusInEvent(event)
55            if obj.hasFocus():
56                self.itemFocusedIn.emit(obj)
57            return True
58        elif event.type() == QEvent.FocusOut:
59            obj.focusOutEvent(event)
60            if not obj.hasFocus():
61                self.itemFocusedOut.emit(obj)
62            return True
63
64        return QGraphicsObject.sceneEventFilter(self, obj, event)
65
66    def boundingRect(self):
67        return QRectF()
68
69
70class SchemeEditWidget(QWidget):
71    undoAvailable = Signal(bool)
72    redoAvailable = Signal(bool)
73    modificationChanged = Signal(bool)
74    undoCommandAdded = Signal()
75    selectionChanged = Signal()
76
77    titleChanged = Signal(unicode)
78
79    pathChanged = Signal(unicode)
80
81    # Quick Menu triggers
82    (NoTriggers,
83     Clicked,
84     DoubleClicked,
85     SpaceKey,
86     AnyKey) = [0, 1, 2, 4, 8]
87
88    def __init__(self, parent=None, ):
89        QWidget.__init__(self, parent)
90
91        self.__modified = False
92        self.__registry = None
93        self.__scheme = None
94        self.__path = u""
95        self.__quickMenuTriggers = SchemeEditWidget.SpaceKey | \
96                                   SchemeEditWidget.DoubleClicked
97        self.__emptyClickButtons = 0
98        self.__channelNamesVisible = True
99        self.__possibleSelectionHandler = None
100        self.__possibleMouseItemsMove = False
101        self.__itemsMoving = {}
102        self.__contextMenuTarget = None
103        self.__quickMenu = None
104        self.__quickTip = ""
105
106        self.__undoStack = QUndoStack(self)
107        self.__undoStack.cleanChanged[bool].connect(self.__onCleanChanged)
108
109        # OWBaseWidget properties when set to clean state
110        self.__cleanSettings = []
111
112        self.__editFinishedMapper = QSignalMapper(self)
113        self.__editFinishedMapper.mapped[QObject].connect(
114            self.__onEditingFinished
115        )
116
117        self.__annotationGeomChanged = QSignalMapper(self)
118
119        self.__setupActions()
120        self.__setupUi()
121
122        self.__editMenu = QMenu(self.tr("&Edit"), self)
123        self.__editMenu.addAction(self.__undoAction)
124        self.__editMenu.addAction(self.__redoAction)
125        self.__editMenu.addSeparator()
126        self.__editMenu.addAction(self.__selectAllAction)
127
128        self.__widgetMenu = QMenu(self.tr("&Widget"), self)
129        self.__widgetMenu.addAction(self.__openSelectedAction)
130        self.__widgetMenu.addSeparator()
131        self.__widgetMenu.addAction(self.__renameAction)
132        self.__widgetMenu.addAction(self.__removeSelectedAction)
133        self.__widgetMenu.addSeparator()
134        self.__widgetMenu.addAction(self.__helpAction)
135
136        self.__linkMenu = QMenu(self.tr("Link"), self)
137        self.__linkMenu.addAction(self.__linkEnableAction)
138        self.__linkMenu.addSeparator()
139        self.__linkMenu.addAction(self.__linkRemoveAction)
140        self.__linkMenu.addAction(self.__linkResetAction)
141
142    def __setupActions(self):
143
144        self.__zoomAction = \
145            QAction(self.tr("Zoom"), self,
146                    objectName="zoom-action",
147                    checkable=True,
148                    shortcut=QKeySequence.ZoomIn,
149                    toolTip=self.tr("Zoom in the scheme."),
150                    toggled=self.toogleZoom,
151                    )
152
153        self.__cleanUpAction = \
154            QAction(self.tr("Clean Up"), self,
155                    objectName="cleanup-action",
156                    toolTip=self.tr("Align widget to a grid."),
157                    triggered=self.alignToGrid,
158                    )
159
160        self.__newTextAnnotationAction = \
161            QAction(self.tr("Text"), self,
162                    objectName="new-text-action",
163                    toolTip=self.tr("Add a text annotation to the scheme."),
164                    checkable=True,
165                    toggled=self.__toggleNewTextAnnotation,
166                    )
167
168        # Create a font size menu for the new annotation action.
169        self.__fontMenu = QMenu("Font Size", self)
170        self.__fontActionGroup = group = \
171            QActionGroup(self, exclusive=True,
172                         triggered=self.__onFontSizeTriggered)
173
174        def font(size):
175            return QFont("Helvetica", size)
176
177        for size in [12, 14, 16, 18, 20, 22, 24]:
178            action = QAction("%ip" % size, group,
179                             checkable=True,
180                             font=font(size))
181
182            self.__fontMenu.addAction(action)
183
184        group.actions()[2].setChecked(True)
185
186        self.__newTextAnnotationAction.setMenu(self.__fontMenu)
187
188        self.__newArrowAnnotationAction = \
189            QAction(self.tr("Arrow"), self,
190                    objectName="new-arrow-action",
191                    toolTip=self.tr("Add a arrow annotation to the scheme."),
192                    checkable=True,
193                    toggled=self.__toggleNewArrowAnnotation,
194                    )
195
196        # Create a color menu for the arrow annotation action
197        self.__arrowColorMenu = QMenu("Arrow Color",)
198        self.__arrowColorActionGroup = group = \
199            QActionGroup(self, exclusive=True,
200                         triggered=self.__onArrowColorTriggered)
201
202        def color_icon(color):
203            icon = QIcon()
204            for size in [16, 24, 32]:
205                pixmap = QPixmap(size, size)
206                pixmap.fill(QColor(0, 0, 0, 0))
207                p = QPainter(pixmap)
208                p.setRenderHint(QPainter.Antialiasing)
209                p.setBrush(color)
210                p.setPen(Qt.NoPen)
211                p.drawEllipse(1, 1, size - 2, size - 2)
212                p.end()
213                icon.addPixmap(pixmap)
214            return icon
215
216        for color in ["#000", "#C1272D", "#662D91", "#1F9CDF", "#39B54A"]:
217            icon = color_icon(QColor(color))
218            action = QAction(group, icon=icon, checkable=True,
219                             iconVisibleInMenu=True)
220            action.setData(color)
221            self.__arrowColorMenu.addAction(action)
222
223        group.actions()[1].setChecked(True)
224
225        self.__newArrowAnnotationAction.setMenu(self.__arrowColorMenu)
226
227        self.__undoAction = self.__undoStack.createUndoAction(self)
228        self.__undoAction.setShortcut(QKeySequence.Undo)
229        self.__undoAction.setObjectName("undo-action")
230
231        self.__redoAction = self.__undoStack.createRedoAction(self)
232        self.__redoAction.setShortcut(QKeySequence.Redo)
233        self.__redoAction.setObjectName("redo-action")
234
235        self.__selectAllAction = \
236            QAction(self.tr("Select all"), self,
237                    objectName="select-all-action",
238                    toolTip=self.tr("Select all items."),
239                    triggered=self.selectAll,
240                    shortcut=QKeySequence.SelectAll
241                    )
242
243        self.__openSelectedAction = \
244            QAction(self.tr("Open"), self,
245                    objectName="open-action",
246                    toolTip=self.tr("Open selected widget"),
247                    triggered=self.openSelected,
248                    enabled=False)
249
250        self.__removeSelectedAction = \
251            QAction(self.tr("Remove"), self,
252                    objectName="remove-selected",
253                    toolTip=self.tr("Remove selected items"),
254                    triggered=self.removeSelected,
255                    enabled=False
256                    )
257
258        shortcuts = [Qt.Key_Delete,
259                     Qt.ControlModifier + Qt.Key_Backspace]
260
261        if sys.platform == "darwin":
262            # Command Backspace should be the first
263            # (visible shortcut in the menu)
264            shortcuts.reverse()
265
266        self.__removeSelectedAction.setShortcuts(shortcuts)
267
268        self.__renameAction = \
269            QAction(self.tr("Rename"), self,
270                    objectName="rename-action",
271                    toolTip=self.tr("Rename selected widget"),
272                    triggered=self.__onRenameAction,
273                    shortcut=QKeySequence(Qt.Key_F2),
274                    enabled=False)
275
276        self.__helpAction = \
277            QAction(self.tr("Help"), self,
278                    objectName="help-action",
279                    toolTip=self.tr("Show widget help"),
280                    triggered=self.__onHelpAction,
281                    shortcut=QKeySequence.HelpContents
282                    )
283
284        self.__linkEnableAction = \
285            QAction(self.tr("Enabled"), self,
286                    objectName="link-enable-action",
287                    triggered=self.__toggleLinkEnabled,
288                    checkable=True,
289                    )
290
291        self.__linkRemoveAction = \
292            QAction(self.tr("Remove"), self,
293                    objectName="link-remove-action",
294                    triggered=self.__linkRemove,
295                    toolTip=self.tr("Remove link."),
296                    )
297
298        self.__linkResetAction = \
299            QAction(self.tr("Reset Signals"), self,
300                    objectName="link-reset-action",
301                    triggered=self.__linkReset,
302                    )
303
304        self.addActions([self.__newTextAnnotationAction,
305                         self.__newArrowAnnotationAction,
306                         self.__linkEnableAction,
307                         self.__linkRemoveAction,
308                         self.__linkResetAction])
309
310        # Actions which should be disabled while a multistep
311        # interaction is in progress.
312        self.__disruptiveActions = \
313                [self.__undoAction,
314                 self.__redoAction,
315                 self.__removeSelectedAction,
316                 self.__selectAllAction]
317
318    def __setupUi(self):
319        layout = QVBoxLayout()
320        layout.setContentsMargins(0, 0, 0, 0)
321        layout.setSpacing(0)
322
323        scene = CanvasScene()
324        scene.set_channel_names_visible(self.__channelNamesVisible)
325
326        view = CanvasView(scene)
327        view.setFrameStyle(CanvasView.NoFrame)
328        view.setRenderHint(QPainter.Antialiasing)
329        view.setContextMenuPolicy(Qt.CustomContextMenu)
330        view.customContextMenuRequested.connect(
331            self.__onCustomContextMenuRequested
332        )
333
334        self.__view = view
335        self.__scene = scene
336
337        self.__focusListener = GraphicsSceneFocusEventListener()
338        self.__focusListener.itemFocusedIn.connect(self.__onItemFocusedIn)
339        self.__focusListener.itemFocusedOut.connect(self.__onItemFocusedOut)
340        self.__scene.addItem(self.__focusListener)
341
342        self.__scene.selectionChanged.connect(
343            self.__onSelectionChanged
344        )
345
346        layout.addWidget(view)
347        self.setLayout(layout)
348
349    def toolbarActions(self):
350        """Return a list of actions that can be inserted into a toolbar.
351        """
352        return [self.__zoomAction,
353                self.__cleanUpAction,
354                self.__newTextAnnotationAction,
355                self.__newArrowAnnotationAction]
356
357    def menuBarActions(self):
358        """Return a list of actions that can be inserted into a QMenuBar.
359        These actions should have a menu.
360
361        """
362        return [self.__editMenu.menuAction(), self.__widgetMenu.menuAction()]
363
364    def isModified(self):
365        """Is the document modified.
366        """
367        return self.__modified or not self.__undoStack.isClean()
368
369    def setModified(self, modified):
370        if self.__modified != modified:
371            self.__modified = modified
372
373        if not modified:
374            self.__cleanSettings = self.__scheme.widget_settings()
375            self.__undoStack.setClean()
376        else:
377            self.__cleanSettings = []
378
379    modified = Property(bool, fget=isModified, fset=setModified)
380
381    def isModifiedStrict(self):
382        """Is the document modified. Run a strict check against all node
383        properties as they were at the time when the last call to
384        `setModified(True)` was made.
385
386        """
387        settingsChanged = self.__cleanSettings != \
388                          self.__scheme.widget_settings()
389
390        log.debug("Modified strict check (modified flag: %s, "
391                  "undo stack clean: %s, properties: %s)",
392                  self.__modified,
393                  self.__undoStack.isClean(),
394                  settingsChanged)
395
396        return self.isModified() or settingsChanged
397
398    def setQuickMenuTriggers(self, triggers):
399        """Set quick menu triggers.
400        """
401        if self.__quickMenuTriggers != triggers:
402            self.__quickMenuTriggers = triggers
403
404    def quickMenuTriggres(self):
405        return self.__quickMenuTriggers
406
407    def setChannelNamesVisible(self, visible):
408        """Set channel names visibility state. When enabled the links
409        in the view will have a visible source/sink channel names
410        displayed over them.
411
412        """
413        if self.__channelNamesVisible != visible:
414            self.__channelNamesVisible = visible
415            self.__scene.set_channel_names_visible(visible)
416
417    def channelNamesVisible(self):
418        """Return the channel name visibility state.
419        """
420        return self.__channelNamesVisible
421
422    def undoStack(self):
423        """Return the undo stack.
424        """
425        return self.__undoStack
426
427    def setPath(self, path):
428        """Set the path associated with the current scheme.
429
430        .. note:: Calling `setScheme` will invalidate the path (i.e. set it
431                  to an empty string)
432
433        """
434        if self.__path != path:
435            self.__path = unicode(path)
436            self.pathChanged.emit(self.__path)
437
438    def path(self):
439        """Return the path associated with the scene
440        """
441        return self.__path
442
443    def setScheme(self, scheme):
444        if self.__scheme is not scheme:
445            if self.__scheme:
446                self.__scheme.title_changed.disconnect(self.titleChanged)
447                self.__scheme.node_added.disconnect(self.__onNodeAdded)
448                self.__scheme.node_removed.disconnect(self.__onNodeRemoved)
449
450            self.__scheme = scheme
451
452            self.setPath("")
453
454            if self.__scheme:
455                self.__scheme.title_changed.connect(self.titleChanged)
456                self.__scheme.node_added.connect(self.__onNodeAdded)
457                self.__scheme.node_removed.connect(self.__onNodeRemoved)
458                self.titleChanged.emit(scheme.title)
459                self.__cleanSettings = scheme.widget_settings()
460            else:
461                self.__cleanSettings = []
462
463            # Clear the current item selection in the scene so edit action
464            # states are updated accordingly.
465            self.__scene.clearSelection()
466
467            self.__annotationGeomChanged.deleteLater()
468            self.__annotationGeomChanged = QSignalMapper(self)
469
470            self.__undoStack.clear()
471
472            self.__focusListener.itemFocusedIn.disconnect(
473                self.__onItemFocusedIn
474            )
475            self.__focusListener.itemFocusedOut.disconnect(
476                self.__onItemFocusedOut
477            )
478
479            self.__scene.selectionChanged.disconnect(
480                self.__onSelectionChanged
481            )
482
483            self.__scene.clear()
484            self.__scene.removeEventFilter(self)
485            self.__scene.deleteLater()
486
487            self.__scene = CanvasScene()
488            self.__view.setScene(self.__scene)
489            self.__scene.set_channel_names_visible(self.__channelNamesVisible)
490
491            self.__scene.installEventFilter(self)
492
493            self.__scene.set_registry(self.__registry)
494
495            # Focus listener
496            self.__focusListener = GraphicsSceneFocusEventListener()
497            self.__focusListener.itemFocusedIn.connect(
498                self.__onItemFocusedIn
499            )
500            self.__focusListener.itemFocusedOut.connect(
501                self.__onItemFocusedOut
502            )
503            self.__scene.addItem(self.__focusListener)
504
505            self.__scene.selectionChanged.connect(
506                self.__onSelectionChanged
507            )
508
509            self.__scene.node_item_activated.connect(
510                self.__onNodeActivate
511            )
512
513            self.__scene.annotation_added.connect(
514                self.__onAnnotationAdded
515            )
516
517            self.__scene.annotation_removed.connect(
518                self.__onAnnotationRemoved
519            )
520
521            self.__scene.set_scheme(scheme)
522
523    def scheme(self):
524        """Return the :class:`Scheme` edited by the widget.
525        """
526        return self.__scheme
527
528    def scene(self):
529        """Return the QGraphicsScene instance used to display the scheme.
530        """
531        return self.__scene
532
533    def view(self):
534        """Return the QGraphicsView instance used to display the scene.
535        """
536        return self.__view
537
538    def setRegistry(self, registry):
539        # Is this method necessary
540        self.__registry = registry
541        if self.__scene:
542            self.__scene.set_registry(registry)
543            self.__quickMenu = None
544
545    def quickMenu(self):
546        """Return a quick menu instance for quick new node creation.
547        """
548        if self.__quickMenu is None:
549            menu = quickmenu.QuickMenu(self)
550            if self.__registry is not None:
551                menu.setModel(self.__registry.model())
552            self.__quickMenu = menu
553        return self.__quickMenu
554
555    def setTitle(self, title):
556        self.__undoStack.push(
557            commands.SetAttrCommand(self.__scheme, "title", title)
558        )
559
560    def setDescription(self, description):
561        self.__undoStack.push(
562            commands.SetAttrCommand(self.__scheme, "description", description)
563        )
564
565    def addNode(self, node):
566        """Add a new node to the scheme.
567        """
568        command = commands.AddNodeCommand(self.__scheme, node)
569        self.__undoStack.push(command)
570
571    def createNewNode(self, description):
572        """Create a new `SchemeNode` and add it to the document at left of the
573        last added node.
574
575        """
576        node = scheme.SchemeNode(description)
577
578        if self.scheme().nodes:
579            x, y = self.scheme().nodes[-1].position
580            node.position = (x + 150, y)
581        else:
582            node.position = (150, 150)
583
584        self.addNode(node)
585
586    def removeNode(self, node):
587        """Remove a `node` (:class:`SchemeNode`) from the scheme
588        """
589        command = commands.RemoveNodeCommand(self.__scheme, node)
590        self.__undoStack.push(command)
591
592    def renameNode(self, node, title):
593        """Rename a `node` (:class:`SchemeNode`) to `title`.
594        """
595        command = commands.RenameNodeCommand(self.__scheme, node, title)
596        self.__undoStack.push(command)
597
598    def addLink(self, link):
599        """Add a `link` (:class:`SchemeLink`) to the scheme.
600        """
601        command = commands.AddLinkCommand(self.__scheme, link)
602        self.__undoStack.push(command)
603
604    def removeLink(self, link):
605        """Remove a link (:class:`SchemeLink`) from the scheme.
606        """
607        command = commands.RemoveLinkCommand(self.__scheme, link)
608        self.__undoStack.push(command)
609
610    def addAnnotation(self, annotation):
611        """Add `annotation` (:class:`BaseSchemeAnnotation`) to the scheme
612        """
613        command = commands.AddAnnotationCommand(self.__scheme, annotation)
614        self.__undoStack.push(command)
615
616    def removeAnnotation(self, annotation):
617        """Remove `annotation` (:class:`BaseSchemeAnnotation`) from the scheme.
618        """
619        command = commands.RemoveAnnotationCommand(self.__scheme, annotation)
620        self.__undoStack.push(command)
621
622    def removeSelected(self):
623        """Remove all selected items in the scheme.
624        """
625        selected = self.scene().selectedItems()
626        if not selected:
627            return
628
629        self.__undoStack.beginMacro(self.tr("Remove"))
630        for item in selected:
631            if isinstance(item, items.NodeItem):
632                node = self.scene().node_for_item(item)
633                self.__undoStack.push(
634                    commands.RemoveNodeCommand(self.__scheme, node)
635                )
636            elif isinstance(item, items.annotationitem.Annotation):
637                annot = self.scene().annotation_for_item(item)
638                self.__undoStack.push(
639                    commands.RemoveAnnotationCommand(self.__scheme, annot)
640                )
641        self.__undoStack.endMacro()
642
643    def selectAll(self):
644        """Select all selectable items in the scheme.
645        """
646        for item in self.__scene.items():
647            if item.flags() & QGraphicsItem.ItemIsSelectable:
648                item.setSelected(True)
649
650    def toogleZoom(self, zoom):
651        view = self.view()
652        if zoom:
653            view.scale(1.5, 1.5)
654        else:
655            view.resetTransform()
656
657    def alignToGrid(self):
658        """Align nodes to a grid.
659        """
660        tile_size = 150
661        tiles = {}
662
663        nodes = sorted(self.scheme().nodes, key=attrgetter("position"))
664
665        if nodes:
666            self.__undoStack.beginMacro(self.tr("Align To Grid"))
667
668            for node in nodes:
669                x, y = node.position
670                x = int(round(float(x) / tile_size) * tile_size)
671                y = int(round(float(y) / tile_size) * tile_size)
672                while (x, y) in tiles:
673                    x += tile_size
674
675                self.__undoStack.push(
676                    commands.MoveNodeCommand(self.scheme(), node,
677                                             node.position, (x, y))
678                )
679
680                tiles[x, y] = node
681                self.__scene.item_for_node(node).setPos(x, y)
682
683            self.__undoStack.endMacro()
684
685    def selectedNodes(self):
686        """Return all selected `SchemeNode` items.
687        """
688        return map(self.scene().node_for_item,
689                   self.scene().selected_node_items())
690
691    def selectedAnnotations(self):
692        """Return all selected `SchemeAnnotation` items.
693        """
694        return map(self.scene().annotation_for_item,
695                   self.scene().selected_annotation_items())
696
697    def openSelected(self):
698        """Open (show and raise) all widgets for selected nodes.
699        """
700        selected = self.scene().selected_node_items()
701        for item in selected:
702            self.__onNodeActivate(item)
703
704    def editNodeTitle(self, node):
705        """Edit the `node`'s title.
706        """
707        name, ok = QInputDialog.getText(
708                    self, self.tr("Rename"),
709                    unicode(self.tr("Enter a new name for the %r widget")) \
710                    % node.title,
711                    text=node.title
712                    )
713
714        if ok:
715            self.__undoStack.push(
716                commands.RenameNodeCommand(self.__scheme, node, node.title,
717                                           unicode(name))
718            )
719
720    def __onCleanChanged(self, clean):
721        if self.isWindowModified() != (not clean):
722            self.setWindowModified(not clean)
723            self.modificationChanged.emit(not clean)
724
725    def eventFilter(self, obj, event):
726        # Filter the scene's drag/drop events.
727        if obj is self.scene():
728            etype = event.type()
729            if  etype == QEvent.GraphicsSceneDragEnter or \
730                    etype == QEvent.GraphicsSceneDragMove:
731                mime_data = event.mimeData()
732                if mime_data.hasFormat(
733                        "application/vnv.orange-canvas.registry.qualified-name"
734                        ):
735                    event.acceptProposedAction()
736                return True
737            elif etype == QEvent.GraphicsSceneDrop:
738                data = event.mimeData()
739                qname = data.data(
740                    "application/vnv.orange-canvas.registry.qualified-name"
741                )
742                desc = self.__registry.widget(unicode(qname))
743                pos = event.scenePos()
744                node = scheme.SchemeNode(desc, position=(pos.x(), pos.y()))
745                self.addNode(node)
746                return True
747
748            elif etype == QEvent.GraphicsSceneMousePress:
749                return self.sceneMousePressEvent(event)
750            elif etype == QEvent.GraphicsSceneMouseMove:
751                return self.sceneMouseMoveEvent(event)
752            elif etype == QEvent.GraphicsSceneMouseRelease:
753                return self.sceneMouseReleaseEvent(event)
754            elif etype == QEvent.GraphicsSceneMouseDoubleClick:
755                return self.sceneMouseDoubleClickEvent(event)
756            elif etype == QEvent.KeyRelease:
757                return self.sceneKeyPressEvent(event)
758            elif etype == QEvent.KeyRelease:
759                return self.sceneKeyReleaseEvent(event)
760            elif etype == QEvent.GraphicsSceneContextMenu:
761                return self.sceneContextMenuEvent(event)
762
763        return QWidget.eventFilter(self, obj, event)
764
765    def sceneMousePressEvent(self, event):
766        scene = self.__scene
767        if scene.user_interaction_handler:
768            return False
769
770        pos = event.scenePos()
771
772        anchor_item = scene.item_at(pos, items.NodeAnchorItem,
773                                    buttons=Qt.LeftButton)
774        if anchor_item and event.button() == Qt.LeftButton:
775            # Start a new link starting at item
776            scene.clearSelection()
777            handler = interactions.NewLinkAction(self)
778            self._setUserInteractionHandler(handler)
779            return handler.mousePressEvent(event)
780
781        any_item = scene.item_at(pos)
782        if not any_item and event.button() == Qt.LeftButton:
783            self.__emptyClickButtons |= Qt.LeftButton
784            # Create a RectangleSelectionAction but do not set in on the scene
785            # just yet (instead wait for the mouse move event).
786            handler = interactions.RectangleSelectionAction(self)
787            rval = handler.mousePressEvent(event)
788            if rval == True:
789                self.__possibleSelectionHandler = handler
790            return rval
791
792        if any_item and event.button() == Qt.LeftButton:
793            self.__possibleMouseItemsMove = True
794            self.__itemsMoving.clear()
795            self.__scene.node_item_position_changed.connect(
796                self.__onNodePositionChanged
797            )
798            self.__annotationGeomChanged.mapped[QObject].connect(
799                self.__onAnnotationGeometryChanged
800            )
801
802            set_enabled_all(self.__disruptiveActions, False)
803
804        return False
805
806    def sceneMouseMoveEvent(self, event):
807        scene = self.__scene
808        if scene.user_interaction_handler:
809            return False
810
811        if self.__emptyClickButtons & Qt.LeftButton and \
812                event.buttons() & Qt.LeftButton and \
813                self.__possibleSelectionHandler:
814            # Set the RectangleSelection (initialized in mousePressEvent)
815            # on the scene
816            handler = self.__possibleSelectionHandler
817            self._setUserInteractionHandler(handler)
818            return handler.mouseMoveEvent(event)
819
820        return False
821
822    def sceneMouseReleaseEvent(self, event):
823        if event.button() == Qt.LeftButton and self.__possibleMouseItemsMove:
824            self.__possibleMouseItemsMove = False
825            self.__scene.node_item_position_changed.disconnect(
826                self.__onNodePositionChanged
827            )
828            self.__annotationGeomChanged.mapped[QObject].disconnect(
829                self.__onAnnotationGeometryChanged
830            )
831
832            set_enabled_all(self.__disruptiveActions, True)
833
834            if self.__itemsMoving:
835                self.__scene.mouseReleaseEvent(event)
836                stack = self.undoStack()
837                stack.beginMacro(self.tr("Move"))
838                for scheme_item, (old, new) in self.__itemsMoving.items():
839                    if isinstance(scheme_item, scheme.SchemeNode):
840                        command = commands.MoveNodeCommand(
841                            self.scheme(), scheme_item, old, new
842                        )
843                    elif isinstance(scheme_item, scheme.BaseSchemeAnnotation):
844                        command = commands.AnnotationGeometryChange(
845                            self.scheme(), scheme_item, old, new
846                        )
847                    else:
848                        continue
849
850                    stack.push(command)
851                stack.endMacro()
852
853                self.__itemsMoving.clear()
854                return True
855
856        if self.__emptyClickButtons & Qt.LeftButton and \
857                event.button() & Qt.LeftButton:
858            self.__emptyClickButtons &= ~Qt.LeftButton
859
860            if self.__quickMenuTriggers & SchemeEditWidget.Clicked and \
861                    mouse_drag_distance(event, Qt.LeftButton) < 1:
862                action = interactions.NewNodeAction(self)
863
864                with nested(disabled(self.__undoAction),
865                            disabled(self.__redoAction)):
866                    action.create_new(event.screenPos())
867
868                event.accept()
869                return True
870
871        return False
872
873    def sceneMouseDoubleClickEvent(self, event):
874        scene = self.__scene
875        if scene.user_interaction_handler:
876            return False
877
878        item = scene.item_at(event.scenePos())
879        if not item and self.__quickMenuTriggers & \
880                SchemeEditWidget.DoubleClicked:
881            # Double click on an empty spot
882            # Create a new node using QuickMenu
883            action = interactions.NewNodeAction(self)
884
885            with nested(disabled(self.__undoAction),
886                        disabled(self.__redoAction)):
887                action.create_new(event.screenPos())
888
889            event.accept()
890            return True
891
892        item = scene.item_at(event.scenePos(), items.LinkItem,
893                             buttons=Qt.LeftButton)
894
895        if item is not None and event.button() == Qt.LeftButton:
896            link = self.scene().link_for_item(item)
897            action = interactions.EditNodeLinksAction(self, link.source_node,
898                                                      link.sink_node)
899            action.edit_links()
900            event.accept()
901            return True
902
903        return False
904
905    def sceneKeyPressEvent(self, event):
906        scene = self.__scene
907        if scene.user_interaction_handler:
908            return False
909
910        # If a QGraphicsItem is in text editing mode, don't interrupt it
911        focusItem = scene.focusItem()
912        if focusItem and isinstance(focusItem, QGraphicsTextItem) and \
913                focusItem.textInteractionFlags() & Qt.TextEditable:
914            return False
915
916        # If the mouse is not over out view
917        if not self.view().underMouse():
918            return False
919
920        handler = None
921        if (event.key() == Qt.Key_Space and \
922                self.__quickMenuTriggers & SchemeEditWidget.SpaceKey):
923            handler = interactions.NewNodeAction(self)
924
925        elif len(event.text()) and \
926                self.__quickMenuTriggers & SchemeEditWidget.AnyKey:
927            handler = interactions.NewNodeAction(self)
928            # TODO: set the search text to event.text() and set focus on the
929            # search line
930
931        if handler is not None:
932            # Control + Backspace (remove widget action on Mac OSX) conflicts
933            # with the 'Clear text' action in the search widget (there might
934            # be selected items in the canvas), so we disable the
935            # remove widget action so the text editing follows standard
936            # 'look and feel'
937            with nested(disabled(self.__removeSelectedAction),
938                        disabled(self.__undoAction),
939                        disabled(self.__redoAction)):
940                handler.create_new(QCursor.pos())
941
942            event.accept()
943            return True
944
945        return False
946
947    def sceneKeyReleaseEvent(self, event):
948        return False
949
950    def sceneContextMenuEvent(self, event):
951        return False
952
953    def _setUserInteractionHandler(self, handler):
954        """Helper method for setting the user interaction handlers.
955        """
956        if self.__scene.user_interaction_handler:
957            if isinstance(self.__scene.user_interaction_handler,
958                          (interactions.ResizeArrowAnnotation,
959                           interactions.ResizeTextAnnotation)):
960                self.__scene.user_interaction_handler.commit()
961
962            self.__scene.user_interaction_handler.ended.disconnect(
963                self.__onInteractionEnded
964            )
965
966        if handler:
967            handler.ended.connect(self.__onInteractionEnded)
968            # Disable actions which could change the model
969            set_enabled_all(self.__disruptiveActions, False)
970
971        self.__scene.set_user_interaction_handler(handler)
972
973    def __onInteractionEnded(self):
974        self.sender().ended.disconnect(self.__onInteractionEnded)
975        set_enabled_all(self.__disruptiveActions, True)
976
977    def __onSelectionChanged(self):
978        nodes = self.selectedNodes()
979        annotations = self.selectedAnnotations()
980
981        self.__openSelectedAction.setEnabled(bool(nodes))
982        self.__removeSelectedAction.setEnabled(
983            bool(nodes) or bool(annotations)
984        )
985
986        self.__helpAction.setEnabled(len(nodes) == 1)
987        self.__renameAction.setEnabled(len(nodes) == 1)
988
989        if len(nodes) > 1:
990            self.__openSelectedAction.setText(self.tr("Open All"))
991        else:
992            self.__openSelectedAction.setText(self.tr("Open"))
993
994        if len(nodes) + len(annotations) > 1:
995            self.__removeSelectedAction.setText(self.tr("Remove All"))
996        else:
997            self.__removeSelectedAction.setText(self.tr("Remove"))
998
999        if len(nodes) == 0:
1000            self.__openSelectedAction.setText(self.tr("Open"))
1001            self.__removeSelectedAction.setText(self.tr("Remove"))
1002
1003        focus = self.__scene.focusItem()
1004        if isinstance(focus, items.NodeItem):
1005            node = self.__scene.node_for_item(focus)
1006            desc = node.description
1007            tip = whats_this_helper(desc)
1008        else:
1009            tip = ""
1010
1011        if tip != self.__quickTip:
1012            self.__quickTip = tip
1013            ev = QuickHelpTipEvent("", self.__quickTip,
1014                                   priority=QuickHelpTipEvent.Permanent)
1015
1016            QCoreApplication.sendEvent(self, ev)
1017
1018    def __onNodeAdded(self, node):
1019        widget = self.__scheme.widget_for_node[node]
1020        widget.widgetStateChanged.connect(self.__onWidgetStateChanged)
1021
1022    def __onNodeRemoved(self, node):
1023        widget = self.__scheme.widget_for_node[node]
1024        widget.widgetStateChanged.disconnect(self.__onWidgetStateChanged)
1025
1026    def __onWidgetStateChanged(self, *args):
1027        widget = self.sender()
1028        self.scheme()
1029        widget_to_node = dict(reversed(item) for item in \
1030                              self.__scheme.widget_for_node.items())
1031        node = widget_to_node[widget]
1032        item = self.__scene.item_for_node(node)
1033
1034        info = widget.widgetStateToHtml(True, False, False)
1035        warning = widget.widgetStateToHtml(False, True, False)
1036        error = widget.widgetStateToHtml(False, False, True)
1037
1038        item.setInfoMessage(info or None)
1039        item.setWarningMessage(warning or None)
1040        item.setErrorMessage(error or None)
1041
1042    def __onNodeActivate(self, item):
1043        node = self.__scene.node_for_item(item)
1044        widget = self.scheme().widget_for_node[node]
1045        widget.show()
1046        widget.raise_()
1047
1048    def __onNodePositionChanged(self, item, pos):
1049        node = self.__scene.node_for_item(item)
1050        new = (pos.x(), pos.y())
1051        if node not in self.__itemsMoving:
1052            self.__itemsMoving[node] = (node.position, new)
1053        else:
1054            old, _ = self.__itemsMoving[node]
1055            self.__itemsMoving[node] = (old, new)
1056
1057    def __onAnnotationGeometryChanged(self, item):
1058        annot = self.scene().annotation_for_item(item)
1059        if annot not in self.__itemsMoving:
1060            self.__itemsMoving[annot] = (annot.geometry,
1061                                         geometry_from_annotation_item(item))
1062        else:
1063            old, _ = self.__itemsMoving[annot]
1064            self.__itemsMoving[annot] = (old,
1065                                         geometry_from_annotation_item(item))
1066
1067    def __onAnnotationAdded(self, item):
1068        log.debug("Annotation added (%r)", item)
1069        item.setFlag(QGraphicsItem.ItemIsSelectable)
1070        item.setFlag(QGraphicsItem.ItemIsMovable)
1071        item.setFlag(QGraphicsItem.ItemIsFocusable)
1072
1073        item.installSceneEventFilter(self.__focusListener)
1074
1075        if isinstance(item, items.ArrowAnnotation):
1076            pass
1077        elif isinstance(item, items.TextAnnotation):
1078            # Make the annotation editable.
1079            item.setTextInteractionFlags(Qt.TextEditorInteraction)
1080
1081            self.__editFinishedMapper.setMapping(item, item)
1082            item.editingFinished.connect(
1083                self.__editFinishedMapper.map
1084            )
1085
1086        self.__annotationGeomChanged.setMapping(item, item)
1087        item.geometryChanged.connect(
1088            self.__annotationGeomChanged.map
1089        )
1090
1091    def __onAnnotationRemoved(self, item):
1092        log.debug("Annotation removed (%r)", item)
1093        if isinstance(item, items.ArrowAnnotation):
1094            pass
1095        elif isinstance(item, items.TextAnnotation):
1096            item.editingFinished.disconnect(
1097                self.__editFinishedMapper.map
1098            )
1099
1100        item.removeSceneEventFilter(self.__focusListener)
1101
1102        self.__annotationGeomChanged.removeMappings(item)
1103        item.geometryChanged.disconnect(
1104            self.__annotationGeomChanged.map
1105        )
1106
1107    def __onItemFocusedIn(self, item):
1108        """Annotation item has gained focus.
1109        """
1110        if not self.__scene.user_interaction_handler:
1111            self.__startControlPointEdit(item)
1112
1113    def __onItemFocusedOut(self, item):
1114        """Annotation item lost focus.
1115        """
1116        self.__endControlPointEdit()
1117
1118    def __onEditingFinished(self, item):
1119        """Text annotation editing has finished.
1120        """
1121        annot = self.__scene.annotation_for_item(item)
1122        text = unicode(item.toPlainText())
1123        if annot.text != text:
1124            self.__undoStack.push(
1125                commands.TextChangeCommand(self.scheme(), annot,
1126                                           annot.text, text)
1127            )
1128
1129    def __toggleNewArrowAnnotation(self, checked):
1130        if self.__newTextAnnotationAction.isChecked():
1131            self.__newTextAnnotationAction.setChecked(not checked)
1132
1133        action = self.__newArrowAnnotationAction
1134
1135        if not checked:
1136            handler = self.__scene.user_interaction_handler
1137            if isinstance(handler, interactions.NewArrowAnnotation):
1138                # Cancel the interaction and restore the state
1139                handler.ended.disconnect(action.toggle)
1140                handler.cancel(interactions.UserInteraction.UserCancelReason)
1141                log.info("Canceled new arrow annotation")
1142
1143        else:
1144            handler = interactions.NewArrowAnnotation(self)
1145            checked = self.__arrowColorActionGroup.checkedAction()
1146            handler.setColor(checked.data().toPyObject())
1147
1148            handler.ended.connect(action.toggle)
1149
1150            self._setUserInteractionHandler(handler)
1151
1152    def __onFontSizeTriggered(self, action):
1153        if not self.__newTextAnnotationAction.isChecked():
1154            # Trigger the action
1155            self.__newTextAnnotationAction.trigger()
1156        else:
1157            # just update the preferred font on the interaction handler
1158            handler = self.__scene.user_interaction_handler
1159            if isinstance(handler, interactions.NewTextAnnotation):
1160                handler.setFont(action.font())
1161
1162    def __toggleNewTextAnnotation(self, checked):
1163        if self.__newArrowAnnotationAction.isChecked():
1164            self.__newArrowAnnotationAction.setChecked(not checked)
1165
1166        action = self.__newTextAnnotationAction
1167
1168        if not checked:
1169            handler = self.__scene.user_interaction_handler
1170            if isinstance(handler, interactions.NewTextAnnotation):
1171                # cancel the interaction and restore the state
1172                handler.ended.disconnect(action.toggle)
1173                handler.cancel(interactions.UserInteraction.UserCancelReason)
1174                log.info("Canceled new text annotation")
1175
1176        else:
1177            handler = interactions.NewTextAnnotation(self)
1178            checked = self.__fontActionGroup.checkedAction()
1179            handler.setFont(checked.font())
1180
1181            handler.ended.connect(action.toggle)
1182
1183            self._setUserInteractionHandler(handler)
1184
1185    def __onArrowColorTriggered(self, action):
1186        if not self.__newArrowAnnotationAction.isChecked():
1187            # Trigger the action
1188            self.__newArrowAnnotationAction.trigger()
1189        else:
1190            # just update the preferred color on the interaction handler
1191            handler = self.__scene.user_interaction_handler
1192            if isinstance(handler, interactions.NewArrowAnnotation):
1193                handler.setColor(action.data().toPyObject())
1194
1195    def __onCustomContextMenuRequested(self, pos):
1196        scenePos = self.view().mapToScene(pos)
1197        globalPos = self.view().mapToGlobal(pos)
1198
1199        item = self.scene().item_at(scenePos, items.NodeItem)
1200        if item is not None:
1201            self.__widgetMenu.popup(globalPos)
1202            return
1203
1204        item = self.scene().item_at(scenePos, items.LinkItem,
1205                                    buttons=Qt.RightButton)
1206        if item is not None:
1207            link = self.scene().link_for_item(item)
1208            self.__linkEnableAction.setChecked(link.enabled)
1209            self.__contextMenuTarget = link
1210            self.__linkMenu.popup(globalPos)
1211            return
1212
1213    def __onRenameAction(self):
1214        selected = self.selectedNodes()
1215        if len(selected) == 1:
1216            self.editNodeTitle(selected[0])
1217
1218    def __onHelpAction(self):
1219        """Help was requested for the selected widget.
1220        """
1221        nodes = self.selectedNodes()
1222        help_url = None
1223        if len(nodes) == 1:
1224            node = nodes[0]
1225            desc = node.description
1226
1227            help_url = "help://search?" + urlencode({"id": desc.id})
1228
1229            # Notify the parent chain and let them respond
1230            ev = QWhatsThisClickedEvent(help_url)
1231            handled = QCoreApplication.sendEvent(self, ev)
1232
1233            if not handled:
1234                message_information(
1235                    self.tr("Sorry there is no documentation available for "
1236                            "this widget."),
1237                    parent=self)
1238
1239    def __toggleLinkEnabled(self, enabled):
1240        """Link enabled state was toggled in the context menu.
1241        """
1242        if self.__contextMenuTarget:
1243            link = self.__contextMenuTarget
1244            command = commands.SetAttrCommand(
1245                link, "enabled", enabled, name=self.tr("Set enabled"),
1246            )
1247            self.__undoStack.push(command)
1248
1249    def __linkRemove(self):
1250        """Remove link was requested from the context menu.
1251        """
1252        if self.__contextMenuTarget:
1253            self.removeLink(self.__contextMenuTarget)
1254
1255    def __linkReset(self):
1256        """Link reset from the context menu was requested.
1257        """
1258        if self.__contextMenuTarget:
1259            link = self.__contextMenuTarget
1260            action = interactions.EditNodeLinksAction(
1261                self, link.source_node, link.sink_node
1262            )
1263            action.edit_links()
1264
1265    def __startControlPointEdit(self, item):
1266        """Start a control point edit interaction for item.
1267        """
1268        if isinstance(item, items.ArrowAnnotation):
1269            handler = interactions.ResizeArrowAnnotation(self)
1270        elif isinstance(item, items.TextAnnotation):
1271            handler = interactions.ResizeTextAnnotation(self)
1272        else:
1273            log.warning("Unknown annotation item type %r" % item)
1274            return
1275
1276        handler.editItem(item)
1277        self._setUserInteractionHandler(handler)
1278
1279        log.info("Control point editing started (%r)." % item)
1280
1281    def __endControlPointEdit(self):
1282        """End the current control point edit interaction.
1283        """
1284        handler = self.__scene.user_interaction_handler
1285        if isinstance(handler, (interactions.ResizeArrowAnnotation,
1286                                interactions.ResizeTextAnnotation)) and \
1287                not handler.isFinished() and not handler.isCanceled():
1288            handler.commit()
1289            handler.end()
1290
1291            log.info("Control point editing finished.")
1292
1293
1294def geometry_from_annotation_item(item):
1295    if isinstance(item, items.ArrowAnnotation):
1296        line = item.line()
1297        p1 = item.mapToScene(line.p1())
1298        p2 = item.mapToScene(line.p2())
1299        return ((p1.x(), p1.y()), (p2.x(), p2.y()))
1300    elif isinstance(item, items.TextAnnotation):
1301        geom = item.geometry()
1302        return (geom.x(), geom.y(), geom.width(), geom.height())
1303
1304
1305def mouse_drag_distance(event, button=Qt.LeftButton):
1306    """Return the (manhattan) distance between the (screen position)
1307    when the `button` was pressed and the current mouse position.
1308
1309    """
1310    diff = (event.buttonDownScreenPos(button) - event.screenPos())
1311    return diff.manhattanLength()
1312
1313
1314def set_enabled_all(objects, enable):
1315    """Set enabled properties on all objects (QObjects with setEnabled).
1316    """
1317    for obj in objects:
1318        obj.setEnabled(enable)
Note: See TracBrowser for help on using the repository browser.