source: orange/Orange/OrangeCanvas/canvas/interactions.py @ 11113:daeb90d45e33

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

Added a CanvasScene class for displaying/interacting with a workflow scheme.

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                link = scheme.SchemeLink(source_node, source, sink_node, sink)
273                self.scene.add_link(link)
274                self.scene.commit_scheme_link(link)
275
276        except scheme.IncompatibleChannelTypeError:
277            log.info("Cannot connect: invalid channel types.")
278            self.cancel()
279        except scheme.SchemeTopologyError:
280            log.info("Cannot connect: connection creates a cycle.")
281            self.cancel()
282        except NoPossibleLinksError:
283            log.info("Cannot connect: no possible links.")
284            self.cancel()
285        except Exception:
286            log.error("An error occurred during the creation of a new link.",
287                      exc_info=True)
288            self.cancel()
289
290        self.end()
291
292    def end(self):
293        # Remove temporary scene objects.
294        if self.cursor_anchor_point and self.cursor_anchor_point.scene():
295            self.scene.removeItem(self.cursor_anchor_point)
296
297        if self.current_target_item:
298            self.remove_tmp_anchor()
299
300        if self.tmp_link_item:
301            self.tmp_link_item.removeLink()
302
303        UserInteraction.end(self)
304
305    def cancel(self):
306        if not self.finished:
307            log.info("Canceling new link action, reverting scene state.")
308            self.tmp_link_item.setSourceItem(None)
309            self.tmp_link_item.setSinkItem(None)
310            self.tmp_link_item.hide()
311            self.tmp_link_item.removeLink()
312
313            self.scene.removeItem(self.tmp_link_item)
314            if self.cursor_anchor_point.scene() is self.scene:
315                self.scene.removeItem(self.cursor_anchor_point)
316
317            if self.current_target_item:
318                self.remove_tmp_anchor()
319
320
321class NewNodeAction(UserInteraction):
322    """Present the user with a quick menu for node selection and
323    create the selected node.
324
325    """
326
327    def __init__(self, scene):
328        UserInteraction.__init__(self, scene)
329
330    def mouse_press_event(self, event):
331        if event.button() == Qt.RightButton:
332            self.create_new(event)
333            self.end()
334
335    def create_new(self, event):
336        """Create a new widget with a QuickWidgetMenu
337        """
338        pos = event.screenPos()
339        quick_menu = self.scene.quick_menu()
340
341        action = quick_menu.exec_(pos)
342        if action:
343            item = action.property("item").toPyObject()
344            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE).toPyObject()
345            pos = event.scenePos()
346            node = scheme.SchemeNode(desc, position=(pos.x(), pos.y()))
347            self.scene.add_node(node)
348            self.scene.commit_scheme_node(node)
349            return node
350
351
352class RectangleSelectionAction(UserInteraction):
353    """Select items in the scene using a Rectangle selection
354    """
355    def __init__(self, scene):
356        UserInteraction.__init__(self, scene)
357        self.initial_selection = None
358
359    def mouse_press_event(self, event):
360        pos = event.scenePos()
361        self.selection_rect = QRectF(pos, QSizeF(0, 0))
362        self.rect_item = QGraphicsRectItem(self.selection_rect.normalized())
363        self.rect_item.setPen(
364            QPen(QBrush(QColor(51, 153, 255, 192)),
365                 0.4, Qt.SolidLine, Qt.RoundCap)
366        )
367        self.rect_item.setBrush(
368            QBrush(QColor(168, 202, 236, 192))
369        )
370        self.rect_item.setZValue(-100)
371        self.scene.addItem(self.rect_item)
372
373    def mouse_move_event(self, event):
374        self.update_selection(event)
375
376    def mouse_release_event(self, event):
377        self.update_selection(event)
378        self.end()
379
380    def update_selection(self, event):
381        if self.initial_selection is None:
382            self.initial_selection = self.scene.selectedItems()
383
384        pos = event.scenePos()
385        self.selection_rect = QRectF(self.selection_rect.topLeft(), pos)
386        self.rect_item.setRect(self.selection_rect.normalized())
387
388        selected = self.scene.items(self.selection_rect.normalized(),
389                                    Qt.IntersectsItemShape,
390                                    Qt.AscendingOrder)
391
392        selected = [item for item in selected if \
393                    item.flags() & Qt.ItemIsSelectable]
394        if event.modifiers() & Qt.ControlModifier:
395            for item in selected:
396                item.setSelected(item not in self.initial_selection)
397        else:
398            for item in self.initial_selection:
399                item.setSelected(False)
400            for item in selected:
401                item.setSelected(True)
402
403    def end(self):
404        self.initial_selection = None
405        self.rect_item.hide()
406        self.scene.removeItem(self.rect_item)
407        UserInteraction.end(self)
408
409
410class EditNodeLinksAction(UserInteraction):
411    def __init__(self, scene, source_node, sink_node):
412        UserInteraction.__init__(self, scene)
413        self.source_node = source_node
414        self.sink_node = sink_node
415
416    def edit_links(self):
417        from .editlinksdialog import EditLinksDialog
418
419        log.info("Constructing a Link Editor dialog.")
420
421        parent = self.scene.views()[0]
422        dlg = EditLinksDialog(parent)
423
424        links = self.scheme.find_links(source_node=self.source_node,
425                                       sink_node=self.sink_node)
426        existing_links = [(link.source_channel, link.sink_channel)
427                          for link in links]
428
429        dlg.setNodes(self.source_node, self.sink_node)
430        dlg.setLinks(existing_links)
431
432        log.info("Executing a Link Editor Dialog.")
433        rval = dlg.exec_()
434
435        if rval == EditLinksDialog.Accepted:
436            links = dlg.links()
437
438            links_to_add = set(links) - set(existing_links)
439            links_to_remove = set(existing_links) - set(links)
440
441            for source_channel, sink_channel in links_to_remove:
442                links = self.scheme.find_links(source_node=self.source_node,
443                                               source_channel=source_channel,
444                                               sink_node=self.sink_node,
445                                               sink_channel=sink_channel)
446                self.scheme.remove_link(links[0])
447
448            for source_channel, sink_channel in links_to_add:
449                link = scheme.SchemeLink(self.source_node, source_channel,
450                                         self.sink_node, sink_channel)
451                self.scheme.add_link(link)
452
453
454def point_to_tuple(point):
455    return point.x(), point.y()
456
457
458class NewArrowAnnotation(UserInteraction):
459    """Create a new arrow annotation.
460    """
461    def __init__(self, scene, ):
462        UserInteraction.__init__(self, scene)
463        self.down_pos = None
464        self.arrow_item = None
465        self.annotation = None
466
467    def mouse_press_event(self, event):
468        if event.button() == Qt.LeftButton:
469            self.down_pos = event.scenePos()
470            event.accept()
471            return True
472
473    def mouse_move_event(self, event):
474        if event.buttons() & Qt.LeftButton:
475            if self.arrow_item is None and \
476                    (self.down_pos - event.scenePos()).manhattanLength() > \
477                    QApplication.instance().startDragDistance():
478
479                annot = scheme.SchemeArrowAnnotation(
480                    point_to_tuple(self.down_pos),
481                    point_to_tuple(event.scenePos())
482                )
483                item = self.scene.add_annotation(annot)
484                self.arrow_item = item
485                self.annotation = annot
486
487            if self.arrow_item is not None:
488                self.arrow_item.setLine(QLineF(self.down_pos,
489                                               event.scenePos()))
490            event.accept()
491            return True
492
493    def mouse_release_event(self, event):
494        if event.button() == Qt.LeftButton:
495            if self.arrow_item is not None:
496                line = QLineF(self.down_pos, event.scenePos())
497
498                # Commit the annotation to the scheme
499                self.annotation.set_line(point_to_tuple(line.p1()),
500                                         point_to_tuple(line.p2()))
501                self.scheme.add_annotation(self.annotation)
502
503                self.arrow_item.setLine(line)
504                self.arrow_item.adjustGeometry()
505
506            self.end()
507            return True
508
509    def end(self):
510        self.down_pos = None
511        self.arrow_item = None
512        self.annotation = None
513        UserInteraction.end(self)
514
515
516def rect_to_tuple(rect):
517    return rect.x(), rect.y(), rect.width(), rect.height()
518
519
520class NewTextAnnotation(UserInteraction):
521    def __init__(self, scene):
522        UserInteraction.__init__(self, scene)
523        self.down_pos = None
524        self.annotation_item = None
525        self.annotation = None
526
527    def mouse_press_event(self, event):
528        if event.button() == Qt.LeftButton:
529            self.down_pos = event.scenePos()
530            return True
531
532    def mouse_move_event(self, event):
533        if event.buttons() & Qt.LeftButton:
534            if self.annotation_item is None and \
535                    (self.down_pos - event.scenePos()).manhattanLength() > \
536                    QApplication.instance().startDragDistance():
537                rect = QRectF(self.down_pos, event.scenePos()).normalized()
538                annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
539
540                item = self.scene.add_annotation(annot)
541                item.setTextInteractionFlags(Qt.TextEditorInteraction)
542
543                self.annotation_item = item
544                self.annotation = annot
545
546            if self.annotation_item is not None:
547                rect = QRectF(self.down_pos, event.scenePos()).normalized()
548                self.annotation_item.setGeometry(rect)
549            return True
550
551    def mouse_release_event(self, event):
552        if event.button() == Qt.LeftButton:
553            if self.annotation_item is not None:
554                rect = QRectF(self.down_pos, event.scenePos()).normalized()
555
556                # Commit the annotation to the scheme.
557                self.annotation.rect = rect_to_tuple(rect)
558                self.scheme.add_annotation(self.annotation)
559
560                self.annotation_item.setGeometry(rect)
561
562                # Move the focus to the editor.
563                self.annotation_item.setFocus(Qt.OtherFocusReason)
564                self.annotation_item.startEdit()
565
566            self.end()
567
568    def end(self):
569        self.down_pos = None
570        self.annotation_item = None
571        self.annotation = None
572        UserInteraction.end(self)
Note: See TracBrowser for help on using the repository browser.