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

Revision 11113:daeb90d45e33, 22.4 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"""
2Canvas Graphics Scene
3
4"""
5
6import logging
7
8from PyQt4.QtGui import QGraphicsScene, QPainter, QBrush, \
9                        QGraphicsItem
10
11from PyQt4.QtCore import Qt, QPointF, QRectF, QSizeF, QLineF, \
12                         QBuffer, QSignalMapper
13
14from PyQt4.QtCore import pyqtSignal as Signal
15from PyQt4.QtCore import PYQT_VERSION_STR
16
17import sip
18
19from .. import scheme
20
21from . import items
22from . import quickmenu
23from . import interactions
24
25
26log = logging.getLogger(__name__)
27
28
29def typed_signal_mapper(pyType):
30    """Create a TypedSignalMapper class supporting signal
31    mapping for `pyType` (the default QSigalMapper only supports
32    int, string, QObject and QWidget (but not for instance QGraphicsItem).
33
34    """
35    class TypedSignalMapper(QSignalMapper):
36        pyMapped = Signal(pyType)
37
38        def __init__(self, parent=None):
39            QSignalMapper.__init__(self, parent)
40            self._mapping = {}
41
42        def setPyMapping(self, sender, mapped):
43            self._mapping[sender] = mapped
44
45        def removePyMappings(self, sender):
46            del self._mapping[sender]
47
48        def pyMap(self, sender=None):
49            if sender is None:
50                sender = self.sender()
51            mapped = self._mapping[sender]
52            self.pyMapped.emit(mapped)
53
54    return TypedSignalMapper
55
56
57NodeItemSignalMapper = typed_signal_mapper(items.NodeItem)
58
59
60class CanvasScene(QGraphicsScene):
61    """A Graphics Scene for displaying and editing an Orange Scheme.
62    """
63
64    node_item_added = Signal(items.NodeItem)
65    """An node item has been added to the scene"""
66
67    node_item_removed = Signal(items.LinkItem)
68    """An node item has been removed from the scene"""
69
70    node_item_position_changed = Signal(items.NodeItem, QPointF)
71    """The position of a node has changed"""
72
73    node_item_double_clicked = Signal(items.NodeItem)
74    """An node item has been double clicked"""
75
76    node_item_activated = Signal(items.NodeItem)
77    """An node item has been activated (clicked)"""
78
79    node_item_hovered = Signal(items.NodeItem)
80    """An node item has been hovered"""
81
82    link_item_added = Signal(items.LinkItem)
83    """A new link item has been added to the scene"""
84
85    link_item_removed = Signal(items.LinkItem)
86    """Link item has been removed"""
87
88    link_item_hovered = Signal(items.LinkItem)
89    """Link item has been hovered"""
90
91    annotation_added = Signal(items.annotationitem.Annotation)
92    """Annotation item has been added"""
93
94    annotation_removed = Signal(items.annotationitem.Annotation)
95    """Annotation item has been removed"""
96
97    def __init__(self, *args, **kwargs):
98        QGraphicsScene.__init__(self, *args, **kwargs)
99        self.scheme = None
100        self.registry = None
101
102        # All node items
103        self.__node_items = []
104        # Mapping from SchemeNodes to canvas items
105        self.__item_for_node = {}
106        # All link items
107        self.__link_items = []
108        # Mapping from SchemeLinks to canvas items.
109        self.__item_for_link = {}
110
111        # All annotation items
112        self.__annotation_items = []
113        # Mapping from SchemeAnnotations to canvas items.
114        self.__item_for_annotation = {}
115
116        # Is the scene editable
117        self.editable = True
118
119        self.user_interaction_handler = None
120        self._quick_menu = None
121
122        self.activated_mapper = NodeItemSignalMapper(self)
123        self.activated_mapper.pyMapped.connect(
124            self.node_item_activated
125        )
126
127        self.hovered_mapper = NodeItemSignalMapper(self)
128        self.hovered_mapper.pyMapped.connect(
129            self.node_item_hovered
130        )
131
132        self.position_change_mapper = NodeItemSignalMapper(self)
133        self.position_change_mapper.pyMapped.connect(
134            self._on_position_change
135        )
136
137        log.info("'%s' intitialized." % self)
138
139    def clear_scene(self):
140        self.scheme = None
141        self.__node_items = []
142        self.__item_for_node = {}
143        self.__link_items = []
144        self.__item_for_link = {}
145
146        self.user_interaction_handler = None
147        self._quick_menu = None
148
149        self.clear()
150        log.info("'%s' cleared." % self)
151
152    def set_scheme(self, scheme):
153        """Set the scheme to display and edit. Populates the scene
154        with nodes and links already in the scheme.
155
156        """
157        if self.scheme is not None:
158            # Clear the old scheme
159            self.scheme.node_added.disconnect(self.add_node)
160            self.scheme.node_removed.disconnect(self.remove_node)
161
162            self.scheme.link_added.disconnect(self.add_link)
163            self.scheme.link_removed.disconnect(self.remove_link)
164
165            self.scheme.annotation_added.disconnect(self.add_annotation)
166            self.scheme.annotation_removed.disconnect(self.remove_annotation)
167
168            self.scheme.node_state_changed.disconnect(
169                self.on_widget_state_change
170            )
171            self.scheme.channel_state_changed.disconnect(
172                self.on_link_state_change
173            )
174
175            self.clear_scene()
176
177        log.info("Setting scheme '%s' on '%s'" % (scheme, self))
178
179        self.scheme = scheme
180        if self.scheme is not None:
181            self.scheme.node_added.connect(self.add_node)
182            self.scheme.node_removed.connect(self.remove_node)
183
184            self.scheme.link_added.connect(self.add_link)
185            self.scheme.link_removed.connect(self.remove_link)
186
187            self.scheme.annotation_added.connect(self.add_annotation)
188            self.scheme.annotation_removed.connect(self.remove_annotation)
189
190            self.scheme.node_state_changed.connect(
191                self.on_widget_state_change
192            )
193            self.scheme.channel_state_changed.connect(
194                self.on_link_state_change
195            )
196
197            self.scheme.topology_changed.connect(self.on_scheme_change)
198
199        for node in scheme.nodes:
200            self.add_node(node)
201
202        for link in scheme.links:
203            self.add_link(link)
204
205        for annot in scheme.annotations:
206            self.add_annotation(annot)
207
208    def set_registry(self, registry):
209        """Set the widget registry.
210        """
211        log.info("Setting registry '%s on '%s'." % (registry, self))
212        self.registry = registry
213        self._quick_menu = None
214
215    def quick_menu(self):
216        if self._quick_menu is None:
217            menu = quickmenu.QuickMenu()
218            if self.registry:
219                menu.setModel(self.registry.model())
220            self._quick_menu = menu
221        return self._quick_menu
222
223    def add_node_item(self, item):
224        """Add a :class:`NodeItem` instance to the scene.
225        """
226        if item in self.__node_items:
227            raise ValueError("%r is already in the scene." % item)
228
229        if item.pos().isNull():
230            if self.__node_items:
231                pos = self.__node_items[-1].pos() + QPointF(150, 0)
232            else:
233                pos = QPointF(150, 150)
234
235            item.setPos(pos)
236
237        # Set signal mappings
238        self.activated_mapper.setPyMapping(item, item)
239        item.activated.connect(self.activated_mapper.pyMap)
240
241        self.hovered_mapper.setPyMapping(item, item)
242        item.hovered.connect(self.hovered_mapper.pyMap)
243
244        self.position_change_mapper.setPyMapping(item, item)
245        item.positionChanged.connect(self.position_change_mapper.pyMap)
246
247        self.addItem(item)
248
249        self.__node_items.append(item)
250
251        self.node_item_added.emit(item)
252
253        log.info("Added item '%s' to '%s'" % (item, self))
254        return item
255
256    def add_node(self, node):
257        """Add and return a default constructed `NodeItem` for a
258        `SchemeNode` instance. If the node is already in the scene
259        do nothing and just return its item.
260
261        """
262        if node in self.__item_for_node:
263            # Already added
264            return self.__item_for_node[node]
265
266        item = self.new_node_item(node.description)
267
268        if node.position:
269            pos = QPointF(*node.position)
270            item.setPos(pos)
271
272        self.__item_for_node[node] = item
273
274        node.title_changed.connect(item.setTitle)
275        node.progress_changed.connect(item.setProgress)
276        node.processing_state_changed.connect(item.setProcessingState)
277        return self.add_node_item(item)
278
279    def new_node_item(self, widget_desc, category_desc=None):
280        """Construct an new `NodeItem` from a `WidgetDescription`.
281        Optionally also set `CategoryDescription`.
282
283        """
284        item = items.NodeItem()
285        item.setWidgetDescription(widget_desc)
286
287        if category_desc is None and self.registry and widget_desc.category:
288            category_desc = self.registry.category(widget_desc.category)
289
290        if category_desc is None and self.registry is not None:
291            try:
292                category_desc = self.registry.category(widget_desc.category)
293            except KeyError:
294                pass
295
296        if category_desc is not None:
297            item.setWidgetCategory(category_desc)
298
299        return item
300
301    def remove_node_item(self, item):
302        """Remove `item` (:class:`NodeItem`) from the scene.
303        """
304        self.activated_mapper.removePyMappings(item)
305        self.hovered_mapper.removePyMappings(item)
306
307        item.hide()
308        self.removeItem(item)
309        self.__node_items.remove(item)
310
311        self.node_item_removed.emit(item)
312
313        log.info("Removed item '%s' from '%s'" % (item, self))
314
315    def remove_node(self, node):
316        """Remove the `NodeItem` instance that was previously constructed for
317        a `SchemeNode` node using the `add_node` method.
318
319        """
320        item = self.__item_for_node.pop(node)
321        self.remove_node_item(item)
322
323    def node_items(self):
324        """Return all :class:`NodeItem` instances in the scene.
325        """
326        return list(self.__node_items)
327
328    def add_link_item(self, item):
329        """Add a link (:class:`LinkItem`)to the scene.
330        """
331        if item.scene() is not self:
332            self.addItem(item)
333
334        self.__link_items.append(item)
335
336        self.link_item_added.emit(item)
337
338        log.info("Added link %r -> %r to '%s'" % \
339                 (item.sourceItem.title, item.sinkItem.title, self))
340
341        return item
342
343    def add_link(self, scheme_link):
344        """Create and add a `LinkItem` instance for a `SchemeLink`
345        instance. If the link is already in the scene do nothing
346        and just return its `LinkItem`.
347
348        """
349        if scheme_link in self.__item_for_link:
350            return self.__item_for_link[scheme_link]
351
352        source = self.__item_for_node[scheme_link.source_node]
353        sink = self.__item_for_node[scheme_link.sink_node]
354
355        item = self.new_link_item(source, scheme_link.source_channel,
356                                  sink, scheme_link.sink_channel)
357
358        item.setEnabled(scheme_link.enabled)
359        scheme_link.enabled_changed.connect(item.setEnabled)
360        self.add_link_item(item)
361        self.__item_for_link[scheme_link] = item
362        return item
363
364    def new_link_item(self, source_item, source_channel,
365                      sink_item, sink_channel):
366        """Construct and return a new `LinkItem`
367        """
368        item = items.LinkItem()
369        item.setSourceItem(source_item)
370        item.setSinkItem(sink_item)
371        return item
372
373    def remove_link_item(self, item):
374        """Remove a link (:class:`LinkItem`) from the scene.
375        """
376        self.__link_items.remove(item)
377
378        # Remove the anchor points.
379        item.removeLink()
380        self.removeItem(item)
381        self.link_item_removed.emit(item)
382
383        log.info("Removed link '%s' from '%s'" % (item, self))
384
385        return item
386
387    def remove_link(self, scheme_link):
388        """ Remove a `LinkItem` instance that was previously constructed for
389        a `SchemeLink` node using the `add_link` method.
390
391        """
392        item = self.__item_for_link.pop(scheme_link)
393        self.remove_link_item(item)
394
395    def link_items(self):
396        """Return all :class:`LinkItems` in the scene.
397
398        """
399        return list(self.__link_items)
400
401    def add_annotation_item(self, annotation):
402        """Add an `Annotation` item to the scene.
403
404        """
405        self.__annotation_items.append(annotation)
406        self.addItem(annotation)
407        self.annotation_added.emit(annotation)
408        return annotation
409
410    def add_annotation(self, scheme_annot):
411        """Create a new item for :class:`SchemeAnnotation` and add it
412        to the scene. If the `scheme_annot` is already in the scene do
413        nothing and just return its item.
414
415        """
416        if scheme_annot in self.__item_for_annotation:
417            # Already added
418            return self.__item_for_annotation[scheme_annot]
419
420        if isinstance(scheme_annot, scheme.SchemeTextAnnotation):
421            item = items.TextAnnotation()
422            item.setPlainText(scheme_annot.text)
423            x, y, w, h = scheme_annot.rect
424            item.setPos(x, y)
425            item.resize(w, h)
426            item.setTextInteractionFlags(Qt.TextEditorInteraction)
427        elif isinstance(scheme_annot, scheme.SchemeArrowAnnotation):
428            item = items.ArrowAnnotation()
429            start, end = scheme_annot.start_pos, scheme_annot.end_pos
430            item.setLine(QLineF(QPointF(*start), QPointF(*end)))
431
432        self.add_annotation_item(item)
433        self.__item_for_annotation[scheme_annot] = item
434
435        return item
436
437    def remove_annotation_item(self, annotation):
438        """Remove an `Annotation` item from the scene.
439
440        """
441        self.__annotation_item.remove(annotation)
442        self.removeItem(annotation)
443        self.annotation_removed.emit(annotation)
444
445    def remove_annotation(self, scheme_annotation):
446        item = self.__item_for_annotation[scheme_annotation]
447        self.remove_annotation_item(item)
448
449    def annotation_items(self):
450        """Return all `Annotation` items in the scene.
451
452        """
453        return self.__annotation_items
454
455    def item_for_annotation(self, scheme_annotation):
456        return self.__item_for_annotation[scheme_annotation]
457
458    def annotation_for_item(self, item):
459        rev = dict(reversed(item) \
460                   for item in self.__item_for_annotation.items())
461        return rev[item]
462
463    def commit_scheme_node(self, node):
464        """Commit the `node` into the scheme.
465        """
466        if not self.editable:
467            raise Exception("Scheme not editable.")
468
469        if node not in self.__item_for_node:
470            raise ValueError("No 'NodeItem' for node.")
471
472        item = self.__item_for_node[node]
473
474        try:
475            self.scheme.add_node(node)
476        except Exception:
477            log.error("An unexpected error occurred while commiting node '%s'",
478                      node, exc_info=True)
479            # Cleanup (remove the node item)
480            self.remove_node_item(item)
481            raise
482
483        log.info("Commited node '%s' from '%s' to '%s'" % \
484                 (node, self, self.scheme))
485
486    def commit_scheme_link(self, link):
487        """Commit a scheme link.
488        """
489        if not self.editable:
490            raise Exception("Scheme not editable")
491
492        if link not in self.__item_for_link:
493            raise ValueError("No 'LinkItem' for link.")
494
495        self.scheme.add_link(link)
496        log.info("Commited link '%s' from '%s' to '%s'" % \
497                 (link, self, self.scheme))
498
499    def node_for_item(self, item):
500        """Return the `SchemeNode` for the `item`.
501        """
502        rev = dict([(v, k) for k, v in self.__item_for_node.items()])
503        return rev[item]
504
505    def item_for_node(self, node):
506        """Return the :class:`NodeItem` instance for a :class:`SchemeNode`.
507        """
508        return self.__item_for_node[node]
509
510    def link_for_item(self, item):
511        """Return the `SchemeLink for `item` (:class:`LinkItem`).
512        """
513        rev = dict([(v, k) for k, v in self.__item_for_link.items()])
514        return rev[item]
515
516    def item_for_link(self, link):
517        """Return the :class:`LinkItem` for a :class:`SchemeLink`
518        """
519        return self.__item_for_link[link]
520
521    def selected_node_items(self):
522        """Return the selected :class:`NodeItem`'s.
523        """
524        return [item for item in self.__node_items if item.isSelected()]
525
526    def on_widget_state_change(self, widget, state):
527        pass
528
529    def on_link_state_change(self, link, state):
530        pass
531
532    def on_scheme_change(self, ):
533        pass
534
535    def _on_position_change(self, item):
536        self.node_item_position_changed.emit(item, item.pos())
537
538    def item_at(self, pos, type_or_tuple=None):
539        rect = QRectF(pos - QPointF(1.5, 1.5), QSizeF(3, 3))
540        items = self.items(rect)
541        if type_or_tuple:
542            items = [i for i in items if isinstance(i, type_or_tuple)]
543
544        return items[0] if items else None
545
546    if PYQT_VERSION_STR < "4.9":
547        # For QGraphicsObject subclasses items/itemAt return a QGraphicsItem
548        # wrapper instance and not the actual class instance.
549        def itemAt(self, *args, **kwargs):
550            item = QGraphicsScene.itemAt(self, *args, **kwargs)
551            if type(item) is QGraphicsItem:
552                for obj in self.__node_items:
553                    if sip.unwrapinstance(sip.cast(obj, QGraphicsItem)) == \
554                            sip.unwrapinstance(item):
555                        return obj
556            return item
557
558        def items(self, *args, **kwargs):
559            items = QGraphicsScene.items(self, *args, **kwargs)
560            instance_map = \
561                dict((sip.unwrapinstance(sip.cast(obj, QGraphicsItem)), obj)
562                     for obj in self.__node_items)
563            for i, item in enumerate(items):
564                if type(item) is QGraphicsItem and \
565                        sip.unwrapinstance(item) in instance_map:
566                        items[i] = instance_map[sip.unwrapinstance(item)]
567            return items
568
569        def _unwrap_node(self, node):
570            unwrapped = sip.unwrapinstance(node)
571            for item in self.__node_items:
572                unwrapped_item = \
573                    sip.unwrapinstance(sip.cast(item, QGraphicsItem))
574                if unwrapped_item == unwrapped:
575                    return item
576            else:
577                raise ValueError
578
579    def mousePressEvent(self, event):
580        if self.user_interaction_handler and \
581                self.user_interaction_handler.mouse_press_event(event):
582            return
583
584        pos = event.scenePos()
585        anchor_item = self.item_at(pos, items.NodeAnchorItem)
586
587        if anchor_item and event.button() == Qt.LeftButton:
588            # Start a new link starting at item
589            if isinstance(anchor_item, items.SourceAnchorItem):
590                direction = interactions.NewLinkAction.FROM_SOURCE
591            else:
592                direction = interactions.NewLinkAction.FROM_SINK
593            from_item = anchor_item.parentWidgetItem
594            self.set_user_interaction_handler(
595                interactions.NewLinkAction(self, from_item, direction)
596            )
597            event.accept()
598            return
599
600        any_item = self.item_at(pos)
601        if not any_item and event.button() == Qt.LeftButton:
602            # Start rect selection
603            self.set_user_interaction_handler(
604                interactions.RectangleSelectionAction(self)
605            )
606            self.user_interaction_handler.mouse_press_event(event)
607            return
608
609        # Right (context) click on the widget item. If the widget is not
610        # in the current selection then select the widget (only the widget).
611        # Else simply return and let customContextMenuReqested signal
612        # handle it
613        shape_item = self.item_at(pos, items.NodeItem)
614        if shape_item and event.button() == Qt.RightButton and \
615                shape_item.flags() & QGraphicsItem.ItemIsSelectable:
616            if not shape_item.isSelected():
617                self.clearSelection()
618                shape_item.setSelected(True)
619
620        return QGraphicsScene.mousePressEvent(self, event)
621
622    def mouseMoveEvent(self, event):
623        if self.user_interaction_handler and \
624                self.user_interaction_handler.mouse_move_event(event):
625            return
626
627        return QGraphicsScene.mouseMoveEvent(self, event)
628
629    def mouseReleaseEvent(self, event):
630        if self.user_interaction_handler and \
631                self.user_interaction_handler.mouse_release_event(event):
632            return
633        return QGraphicsScene.mouseReleaseEvent(self, event)
634
635    def mouseDoubleClickEvent(self, event):
636        if self.user_interaction_handler and \
637                self.user_interaction_handler.mouse_double_click_event(event):
638            return
639
640        item = self.item_at(event.scenePos())
641        if not item:
642            # Double click on an empty spot
643            # Create a new node quick
644            action = interactions.NewNodeAction(self)
645            action.create_new(event)
646            event.accept()
647            return
648
649        return QGraphicsScene.mouseDoubleClickEvent(self, event)
650
651    def set_user_interaction_handler(self, handler):
652        if self.user_interaction_handler and \
653                not self.user_interaction_handler.finished:
654            self.user_interaction_handler.cancel()
655
656        log.info("Setting interaction '%s' to '%s'" % (handler, self))
657
658        self.user_interaction_handler = handler
659        if handler:
660            handler.start()
661
662    def __str__(self):
663        return "%s(objectName=%r, ...)" % \
664                (type(self).__name__, str(self.objectName()))
665
666
667def grab_svg(scene):
668    """Return a SVG rendering of the scene contents.
669    """
670    from PyQt4.QtSvg import QSvgGenerator
671    svg_buffer = QBuffer()
672    gen = QSvgGenerator()
673    gen.setOutputDevice(svg_buffer)
674    aspect_ratio = 1.618
675    items_rect = scene.itemsBoundingRect()
676
677    if items_rect.isNull():
678        items_rect = QRectF(0, 0, 10, 10)
679
680    width, height = items_rect.width(), items_rect.height()
681    rect_ratio = float(width) / height
682    if rect_ratio > aspect_ratio:
683        height = int(height * rect_ratio / aspect_ratio)
684    else:
685        width = int(width * aspect_ratio / rect_ratio)
686
687    scene_rect = QRectF(0, 0, width + 20, height + 20)
688    scene_rect.moveCenter(items_rect.center())
689    scene.setSceneRect(scene_rect)
690
691    gen.setSize(scene_rect.size().toSize())
692    painter = QPainter(gen)
693    painter.setBrush(QBrush(Qt.white))
694    painter.drawRect(QRectF(0, 0, scene_rect.width(), scene_rect.height()))
695#    print scene_rect, scene_rect.topLeft() - items_rect.topLeft()
696#    painter.translate(items_rect.topLeft() - scene_rect.topLeft())
697    scene.render(painter)
698    painter.end()
699    return unicode(svg_buffer.buffer())
Note: See TracBrowser for help on using the repository browser.