source: orange/Orange/OrangeCanvas/canvas/interactions.py @ 11132:f2def20f3910

Revision 11132:f2def20f3910, 19.8 KB checked in by Ales Erjavec <ales.erjavec@…>, 18 months ago (diff)

Remove temporary link items before creating a new concrete link.

Prevents line flicker after creation.

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