source: orange/Orange/OrangeCanvas/document/interactions.py @ 11159:07b6db67859c

Revision 11159:07b6db67859c, 22.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 18 months ago (diff)

Fixed selection when left clicking on a node.

Line 
1"""
2User interaction handlers for CanvasScene.
3
4"""
5import logging
6
7from PyQt4.QtGui import (
8    QApplication, QGraphicsRectItem, QPen, QBrush, QColor
9)
10
11from PyQt4.QtCore import Qt, QSizeF, QRectF, QLineF
12
13from ..registry.qt import QtWidgetRegistry
14from .. import scheme
15from ..canvas import items
16
17log = logging.getLogger(__name__)
18
19
20class UserInteraction(object):
21    def __init__(self, document):
22        self.document = document
23        self.scene = document.scene()
24        self.scheme = document.scheme()
25        self.finished = False
26        self.canceled = False
27
28    def start(self):
29        pass
30
31    def end(self):
32        self.finished = True
33        if self.scene.user_interaction_handler is self:
34            self.scene.set_user_interaction_handler(None)
35
36    def cancel(self):
37        self.canceled = True
38        self.end()
39
40    def mousePressEvent(self, event):
41        return False
42
43    def mouseMoveEvent(self, event):
44        return False
45
46    def mouseReleaseEvent(self, event):
47        return False
48
49    def mouseDoubleClickEvent(self, event):
50        return False
51
52    def keyPressEvent(self, event):
53        return False
54
55    def keyReleaseEvent(self, event):
56        return False
57
58
59class NoPossibleLinksError(ValueError):
60    pass
61
62
63class NewLinkAction(UserInteraction):
64    """User drags a new link from an existing node anchor item to create
65    a connection between two existing nodes or to a new node if the release
66    is over an empty area, in which case a quick menu for new node selection
67    is presented to the user.
68
69    """
70    # direction of the drag
71    FROM_SOURCE = 1
72    FROM_SINK = 2
73
74    def __init__(self, document):
75        UserInteraction.__init__(self, document)
76        self.source_item = None
77        self.sink_item = None
78        self.from_item = None
79        self.direction = None
80
81        self.current_target_item = None
82        self.tmp_link_item = None
83        self.tmp_anchor_point = None
84        self.cursor_anchor_point = None
85
86    def remove_tmp_anchor(self):
87        """Remove a temp anchor point from the current target item.
88        """
89        if self.direction == self.FROM_SOURCE:
90            self.current_target_item.removeInputAnchor(self.tmp_anchor_point)
91        else:
92            self.current_target_item.removeOutputAnchor(self.tmp_anchor_point)
93        self.tmp_anchor_point = None
94
95    def create_tmp_anchor(self, item):
96        """Create a new tmp anchor at the item (`NodeItem`).
97        """
98        assert(self.tmp_anchor_point is None)
99        if self.direction == self.FROM_SOURCE:
100            self.tmp_anchor_point = item.newInputAnchor()
101        else:
102            self.tmp_anchor_point = item.newOutputAnchor()
103
104    def can_connect(self, target_item):
105        """Is the connection between `self.from_item` (item where the drag
106        started) and `target_item`.
107
108        """
109        node1 = self.scene.node_for_item(self.from_item)
110        node2 = self.scene.node_for_item(target_item)
111
112        if self.direction == self.FROM_SOURCE:
113            return bool(self.scheme.propose_links(node1, node2))
114        else:
115            return bool(self.scheme.propose_links(node2, node1))
116
117    def set_link_target_anchor(self, anchor):
118        """Set the temp line target anchor
119        """
120        if self.direction == self.FROM_SOURCE:
121            self.tmp_link_item.setSinkItem(None, anchor)
122        else:
123            self.tmp_link_item.setSourceItem(None, anchor)
124
125    def target_node_item_at(self, pos):
126        """Return a suitable NodeItem on which a link can be dropped.
127        """
128        # Test for a suitable NodeAnchorItem or NodeItem at pos.
129        if self.direction == self.FROM_SOURCE:
130            anchor_type = items.SinkAnchorItem
131        else:
132            anchor_type = items.SourceAnchorItem
133
134        item = self.scene.item_at(pos, (anchor_type, items.NodeItem))
135
136        if isinstance(item, anchor_type):
137            item = item.parentNodeItem()
138
139        return item
140
141    def mousePressEvent(self, event):
142        anchor_item = self.scene.item_at(event.scenePos(),
143                                         items.NodeAnchorItem)
144        if anchor_item and event.button() == Qt.LeftButton:
145            # Start a new link starting at item
146            self.from_item = anchor_item.parentNodeItem()
147            if isinstance(anchor_item, items.SourceAnchorItem):
148                self.direction = NewLinkAction.FROM_SOURCE
149                self.source_item = self.from_item
150            else:
151                self.direction = NewLinkAction.FROM_SINK
152                self.sink_item = self.from_item
153
154            event.accept()
155            return True
156        else:
157            # Whoerver put us in charge did not know what he was doing.
158            self.cancel()
159            return False
160
161    def mouseMoveEvent(self, event):
162        if not self.tmp_link_item:
163            # On first mouse move event create the temp link item and
164            # initialize it to follow the `cursor_anchor_point`.
165            self.tmp_link_item = items.LinkItem()
166            # An anchor under the cursor for the duration of this action.
167            self.cursor_anchor_point = items.AnchorPoint()
168            self.cursor_anchor_point.setPos(event.scenePos())
169
170            # Set the `fixed` end of the temp link (where the drag started).
171            if self.direction == self.FROM_SOURCE:
172                self.tmp_link_item.setSourceItem(self.source_item)
173            else:
174                self.tmp_link_item.setSinkItem(self.sink_item)
175
176            self.set_link_target_anchor(self.cursor_anchor_point)
177            self.scene.addItem(self.tmp_link_item)
178
179        # `NodeItem` at the cursor position
180        item = self.target_node_item_at(event.scenePos())
181
182        if self.current_target_item is not None and \
183                (item is None or item is not self.current_target_item):
184            # `current_target_item` is no longer under the mouse cursor
185            # (was replaced by another item or the the cursor was moved over
186            # an empty scene spot.
187            log.info("%r is no longer the target.", self.current_target_item)
188            self.remove_tmp_anchor()
189            self.current_target_item = None
190
191        if item is not None and item is not self.from_item:
192            # The mouse is over an node item (different from the starting node)
193            if self.current_target_item is item:
194                # Avoid reseting the points
195                pass
196            elif self.can_connect(item):
197                # Grab a new anchor
198                log.info("%r is the new target.", item)
199                self.create_tmp_anchor(item)
200                self.set_link_target_anchor(self.tmp_anchor_point)
201                self.current_target_item = item
202            else:
203                log.info("%r does not have compatible channels", item)
204                self.set_link_target_anchor(self.cursor_anchor_point)
205                # TODO: How to indicate that the connection is not possible?
206                #       The node's anchor could be drawn with a 'disabled'
207                #       palette
208        else:
209            self.set_link_target_anchor(self.cursor_anchor_point)
210
211        self.cursor_anchor_point.setPos(event.scenePos())
212
213        return True
214
215    def mouseReleaseEvent(self, event):
216        if self.tmp_link_item:
217            item = self.target_node_item_at(event.scenePos())
218            node = None
219            stack = self.document.undoStack()
220            stack.beginMacro("Add link")
221
222            if item:
223                # If the release was over a widget item
224                # then connect them
225                node = self.scene.node_for_item(item)
226            else:
227                # Release on an empty canvas part
228                # Show a quick menu popup for a new widget creation.
229                try:
230                    node = self.create_new(event)
231                    self.document.addNode(node)
232                except Exception:
233                    log.error("Failed to create a new node, ending.")
234                    node = None
235
236            if node is not None:
237                self.connect_existing(node)
238            else:
239                self.end()
240
241            stack.endMacro()
242        else:
243            self.end()
244            return False
245
246    def create_new(self, event):
247        """Create and return a new node with a QuickWidgetMenu.
248        """
249        pos = event.screenPos()
250        quick_menu = self.scene.quick_menu()
251
252        action = quick_menu.exec_(pos)
253
254        if action:
255            item = action.property("item").toPyObject()
256            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
257            pos = event.scenePos()
258            node = scheme.SchemeNode(desc, position=(pos.x(), pos.y()))
259            return node
260
261    def connect_existing(self, node):
262        """Connect anchor_item to `node`.
263        """
264        if self.direction == self.FROM_SOURCE:
265            source_item = self.source_item
266            source_node = self.scene.node_for_item(source_item)
267            sink_node = node
268        else:
269            source_node = node
270            sink_item = self.sink_item
271            sink_node = self.scene.node_for_item(sink_item)
272
273        try:
274            possible = self.scheme.propose_links(source_node, sink_node)
275
276            if not possible:
277                raise NoPossibleLinksError
278
279            links_to_add = []
280            links_to_remove = []
281
282            # Check for possible ties in the proposed link weights
283            if len(possible) >= 2:
284                source, sink, w1 = possible[0]
285                _, _, w2 = possible[1]
286                if w1 == w2:
287                    # If there are ties in the weights a detailed link
288                    # dialog is presented to the user.
289                    links_action = EditNodeLinksAction(
290                                    self.document, source_node, sink_node)
291                    try:
292                        links = links_action.edit_links()
293                    except Exception:
294                        log.error("'EditNodeLinksAction' failed",
295                                  exc_info=True)
296                        raise
297                else:
298                    links_to_add = [(source, sink)]
299            else:
300                source, sink, _ = possible[0]
301                links_to_add = [(source, sink)]
302
303            for source, sink in links_to_remove:
304                existing_link = self.scheme.find_links(
305                                    source_node=source_node,
306                                    source_channel=source,
307                                    sink_node=sink_node,
308                                    sink_channel=sink)
309
310                self.document.removeLink(existing_link)
311
312            for source, sink in links_to_add:
313                if sink.single:
314                    # Remove an existing link to the sink channel if present.
315                    existing_link = self.scheme.find_links(
316                        sink_node=sink_node, sink_channel=sink
317                    )
318
319                    if existing_link:
320                        self.document.removeLink(existing_link[0])
321
322                # Check if the new link is a duplicate of an existing link
323                duplicate = self.scheme.find_links(
324                    source_node, source, sink_node, sink
325                )
326
327                if duplicate:
328                    # Do nothing.
329                    continue
330
331                # Remove temp items before creating a new link
332                self.cleanup()
333
334                link = scheme.SchemeLink(source_node, source, sink_node, sink)
335                self.document.addLink(link)
336
337        except scheme.IncompatibleChannelTypeError:
338            log.info("Cannot connect: invalid channel types.")
339            self.cancel()
340        except scheme.SchemeTopologyError:
341            log.info("Cannot connect: connection creates a cycle.")
342            self.cancel()
343        except NoPossibleLinksError:
344            log.info("Cannot connect: no possible links.")
345            self.cancel()
346        except Exception:
347            log.error("An error occurred during the creation of a new link.",
348                      exc_info=True)
349            self.cancel()
350
351        self.end()
352
353    def end(self):
354        self.cleanup()
355        UserInteraction.end(self)
356
357    def cancel(self):
358        if not self.finished:
359            log.info("Canceling new link action, reverting scene state.")
360            self.cleanup()
361
362    def cleanup(self):
363        """Cleanup all temp items in the scene that are left.
364        """
365        if self.tmp_link_item:
366            self.tmp_link_item.setSinkItem(None)
367            self.tmp_link_item.setSourceItem(None)
368
369            if self.tmp_link_item.scene():
370                self.scene.removeItem(self.tmp_link_item)
371
372            self.tmp_link_item = None
373
374        if self.current_target_item:
375            self.remove_tmp_anchor()
376            self.current_target_item = None
377
378        if self.cursor_anchor_point and self.cursor_anchor_point.scene():
379            self.scene.removeItem(self.cursor_anchor_point)
380            self.cursor_anchor_point = None
381
382
383class NewNodeAction(UserInteraction):
384    """Present the user with a quick menu for node selection and
385    create the selected node.
386
387    """
388
389    def __init__(self, document):
390        UserInteraction.__init__(self, document)
391
392    def mousePressEvent(self, event):
393        if event.button() == Qt.RightButton:
394            self.create_new(event)
395            self.end()
396
397    def create_new(self, event):
398        """Create a new widget with a QuickWidgetMenu
399        """
400        pos = event.screenPos()
401        quick_menu = self.scene.quick_menu()
402
403        action = quick_menu.exec_(pos)
404        if action:
405            item = action.property("item").toPyObject()
406            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
407            pos = event.scenePos()
408            node = scheme.SchemeNode(desc, position=(pos.x(), pos.y()))
409            self.document.addNode(node)
410            return node
411
412
413class RectangleSelectionAction(UserInteraction):
414    """Select items in the scene using a Rectangle selection
415    """
416    def __init__(self, document):
417        UserInteraction.__init__(self, document)
418        self.initial_selection = None
419
420    def mousePressEvent(self, event):
421        pos = event.scenePos()
422        any_item = self.scene.item_at(pos)
423        if not any_item and event.button() & Qt.LeftButton:
424            self.selection_rect = QRectF(pos, QSizeF(0, 0))
425            self.rect_item = QGraphicsRectItem(
426                self.selection_rect.normalized()
427            )
428
429            self.rect_item.setPen(
430                QPen(QBrush(QColor(51, 153, 255, 192)),
431                     0.4, Qt.SolidLine, Qt.RoundCap)
432            )
433
434            self.rect_item.setBrush(
435                QBrush(QColor(168, 202, 236, 192))
436            )
437
438            self.rect_item.setZValue(-100)
439
440            # Clear the focus if necessary.
441            if not self.scene.stickyFocus():
442                self.scene.clearFocus()
443            event.accept()
444            return True
445        else:
446            self.cancel()
447            return False
448
449    def mouseMoveEvent(self, event):
450        if not self.rect_item.scene():
451            self.scene.addItem(self.rect_item)
452        self.update_selection(event)
453
454    def mouseReleaseEvent(self, event):
455        self.update_selection(event)
456        self.end()
457
458    def update_selection(self, event):
459        if self.initial_selection is None:
460            self.initial_selection = self.scene.selectedItems()
461
462        pos = event.scenePos()
463        self.selection_rect = QRectF(self.selection_rect.topLeft(), pos)
464        self.rect_item.setRect(self.selection_rect.normalized())
465
466        selected = self.scene.items(self.selection_rect.normalized(),
467                                    Qt.IntersectsItemShape,
468                                    Qt.AscendingOrder)
469
470        selected = [item for item in selected if \
471                    item.flags() & Qt.ItemIsSelectable]
472        if event.modifiers() & Qt.ControlModifier:
473            for item in selected:
474                item.setSelected(item not in self.initial_selection)
475        else:
476            for item in self.initial_selection:
477                item.setSelected(False)
478            for item in selected:
479                item.setSelected(True)
480
481    def end(self):
482        self.initial_selection = None
483        self.rect_item.hide()
484        if self.rect_item.scene() is not None:
485            self.scene.removeItem(self.rect_item)
486        UserInteraction.end(self)
487
488
489class EditNodeLinksAction(UserInteraction):
490    def __init__(self, document, source_node, sink_node):
491        UserInteraction.__init__(self, document)
492        self.source_node = source_node
493        self.sink_node = sink_node
494
495    def edit_links(self):
496        from ..canvas.editlinksdialog import EditLinksDialog
497
498        log.info("Constructing a Link Editor dialog.")
499
500        parent = self.scene.views()[0]
501        dlg = EditLinksDialog(parent)
502
503        links = self.scheme.find_links(source_node=self.source_node,
504                                       sink_node=self.sink_node)
505        existing_links = [(link.source_channel, link.sink_channel)
506                          for link in links]
507
508        dlg.setNodes(self.source_node, self.sink_node)
509        dlg.setLinks(existing_links)
510
511        log.info("Executing a Link Editor Dialog.")
512        rval = dlg.exec_()
513
514        if rval == EditLinksDialog.Accepted:
515            links = dlg.links()
516
517            links_to_add = set(links) - set(existing_links)
518            links_to_remove = set(existing_links) - set(links)
519
520            stack = self.document.undoStack()
521            stack.beginMacro("Edit Links")
522
523            for source_channel, sink_channel in links_to_remove:
524                links = self.scheme.find_links(source_node=self.source_node,
525                                               source_channel=source_channel,
526                                               sink_node=self.sink_node,
527                                               sink_channel=sink_channel)
528
529                self.document.removeLink(links[0])
530
531            for source_channel, sink_channel in links_to_add:
532                link = scheme.SchemeLink(self.source_node, source_channel,
533                                         self.sink_node, sink_channel)
534
535                self.document.addLink(link)
536            stack.endMacro()
537
538
539def point_to_tuple(point):
540    return point.x(), point.y()
541
542
543class NewArrowAnnotation(UserInteraction):
544    """Create a new arrow annotation.
545    """
546    def __init__(self, document):
547        UserInteraction.__init__(self, document)
548        self.down_pos = None
549        self.arrow_item = None
550        self.annotation = None
551
552    def start(self):
553        self.document.view().setCursor(Qt.CrossCursor)
554        UserInteraction.start(self)
555
556    def mousePressEvent(self, event):
557        if event.button() == Qt.LeftButton:
558            self.down_pos = event.scenePos()
559            event.accept()
560            return True
561
562    def mouseMoveEvent(self, event):
563        if event.buttons() & Qt.LeftButton:
564            if self.arrow_item is None and \
565                    (self.down_pos - event.scenePos()).manhattanLength() > \
566                    QApplication.instance().startDragDistance():
567
568                annot = scheme.SchemeArrowAnnotation(
569                    point_to_tuple(self.down_pos),
570                    point_to_tuple(event.scenePos())
571                )
572                item = self.scene.add_annotation(annot)
573                self.arrow_item = item
574                self.annotation = annot
575
576            if self.arrow_item is not None:
577                self.arrow_item.setLine(QLineF(self.down_pos,
578                                               event.scenePos()))
579            event.accept()
580            return True
581
582    def mouseReleaseEvent(self, event):
583        if event.button() == Qt.LeftButton:
584            if self.arrow_item is not None:
585                line = QLineF(self.down_pos, event.scenePos())
586
587                # Commit the annotation to the scheme
588                self.annotation.set_line(point_to_tuple(line.p1()),
589                                         point_to_tuple(line.p2()))
590                self.document.addAnnotation(self.annotation)
591
592                self.arrow_item.setLine(line)
593                self.arrow_item.adjustGeometry()
594
595            self.end()
596            return True
597
598    def end(self):
599        self.down_pos = None
600        self.arrow_item = None
601        self.annotation = None
602        self.document.view().setCursor(Qt.ArrowCursor)
603        UserInteraction.end(self)
604
605
606def rect_to_tuple(rect):
607    return rect.x(), rect.y(), rect.width(), rect.height()
608
609
610class NewTextAnnotation(UserInteraction):
611    def __init__(self, document):
612        UserInteraction.__init__(self, document)
613        self.down_pos = None
614        self.annotation_item = None
615        self.annotation = None
616
617    def start(self):
618        self.document.view().setCursor(Qt.CrossCursor)
619        UserInteraction.start(self)
620
621    def mousePressEvent(self, event):
622        if event.button() == Qt.LeftButton:
623            self.down_pos = event.scenePos()
624            return True
625
626    def mouseMoveEvent(self, event):
627        if event.buttons() & Qt.LeftButton:
628            if self.annotation_item is None and \
629                    (self.down_pos - event.scenePos()).manhattanLength() > \
630                    QApplication.instance().startDragDistance():
631                rect = QRectF(self.down_pos, event.scenePos()).normalized()
632                annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
633
634                item = self.scene.add_annotation(annot)
635                item.setTextInteractionFlags(Qt.TextEditorInteraction)
636
637                self.annotation_item = item
638                self.annotation = annot
639
640            if self.annotation_item is not None:
641                rect = QRectF(self.down_pos, event.scenePos()).normalized()
642                self.annotation_item.setGeometry(rect)
643            return True
644
645    def mouseReleaseEvent(self, event):
646        if event.button() == Qt.LeftButton:
647            if self.annotation_item is not None:
648                rect = QRectF(self.down_pos, event.scenePos()).normalized()
649
650                # Commit the annotation to the scheme.
651                self.annotation.rect = rect_to_tuple(rect)
652                self.document.addAnnotation(self.annotation)
653
654                self.annotation_item.setGeometry(rect)
655
656                # Move the focus to the editor.
657                self.annotation_item.setFocus(Qt.OtherFocusReason)
658                self.annotation_item.startEdit()
659
660            self.end()
661
662    def end(self):
663        self.down_pos = None
664        self.annotation_item = None
665        self.annotation = None
666        self.document.view().setCursor(Qt.ArrowCursor)
667        UserInteraction.end(self)
Note: See TracBrowser for help on using the repository browser.