source: orange/Orange/OrangeCanvas/canvas/editlinksdialog.py @ 11144:5183fcc7e13e

Revision 11144:5183fcc7e13e, 23.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 18 months ago (diff)

Fixes for compatibility with Qt 4.6.

Line 
1"""
2An Dialog to edit links between two nodes in the scheme.
3
4"""
5
6from collections import namedtuple
7
8from xml.sax.saxutils import escape
9
10from PyQt4.QtGui import (
11    QApplication, QDialog, QVBoxLayout, QDialogButtonBox, QGraphicsScene,
12    QGraphicsView, QGraphicsWidget, QGraphicsRectItem,
13    QGraphicsLineItem, QGraphicsTextItem, QGraphicsLayoutItem,
14    QGraphicsLinearLayout, QGraphicsGridLayout, QGraphicsPixmapItem,
15    QGraphicsDropShadowEffect, QSizePolicy, QPalette, QPen,
16    QPainter
17)
18
19from PyQt4.QtCore import (
20    Qt, QObject, QSize, QSizeF, QPointF, QRectF, qVersion
21)
22
23from PyQt4.QtCore import pyqtSignal as Signal
24
25from ..scheme import SchemeNode, SchemeLink, compatible_channels
26from ..registry import InputSignal, OutputSignal
27
28from ..resources import icon_loader
29
30
31QWIDGETSIZE_MAX = ((1 << 24) - 1)
32
33
34class EditLinksDialog(QDialog):
35    """A dialog for editing links.
36
37    >>> dlg = EditLinksDialog()
38    >>> dlg.setNodes(file_node, test_learners_node)
39    >>> dlg.setLinks([(file_node.output_channel("Data"),
40    ...               (test_learners_node.input_channel("Data")])
41    >>> if dlg.exec_() == EditLinksDialog.Accpeted:
42    ...     new_links = dlg.links()
43    ...
44
45    """
46    def __init__(self, *args, **kwargs):
47        QDialog.__init__(self, *args, **kwargs)
48
49        self.setModal(True)
50
51        self.__setupUi()
52
53    def __setupUi(self):
54        layout = QVBoxLayout()
55
56        # Scene with the link editor.
57        self.scene = LinksEditScene()
58        self.view = QGraphicsView(self.scene)
59        self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
60        self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
61        self.view.setRenderHint(QPainter.Antialiasing)
62
63        self.scene.editWidget.geometryChanged.connect(self.__onGeometryChanged)
64
65        # Ok/Cancel/Clear All buttons.
66        buttons = QDialogButtonBox(QDialogButtonBox.Ok |
67                                   QDialogButtonBox.Cancel |
68                                   QDialogButtonBox.Reset,
69                                   Qt.Horizontal)
70
71        clear_button = buttons.button(QDialogButtonBox.Reset)
72        clear_button.setText(self.tr("Clear All"))
73
74        buttons.accepted.connect(self.accept)
75        buttons.rejected.connect(self.reject)
76        clear_button.clicked.connect(self.scene.editWidget.clearLinks)
77
78        layout.addWidget(self.view)
79        layout.addWidget(buttons)
80
81        self.setLayout(layout)
82        layout.setSizeConstraint(QVBoxLayout.SetFixedSize)
83
84        self.setSizeGripEnabled(False)
85
86    def setNodes(self, source_node, sink_node):
87        """Set the source/sink nodes (`SchemeNode` instances)
88        between which to edit the links.
89
90        """
91        self.scene.editWidget.setNodes(source_node, sink_node)
92
93    def setLinks(self, links):
94        """Set a list of links to display between the source and sink
95        nodes. The `links` is a list of (`OutputSignal`, `InputSignal`)
96        instances where the first element refers to the source node
97        and the second to the sink node.
98
99        """
100        self.scene.editWidget.setLinks(links)
101
102    def links(self):
103        """Return the links between the source and sink node.
104        """
105        return self.scene.editWidget.links()
106
107    def __onGeometryChanged(self):
108        size = self.scene.editWidget.size()
109        left, top, right, bottom = self.getContentsMargins()
110        self.view.setFixedSize(size.toSize() + \
111                               QSize(left + right + 4, top + bottom + 4))
112
113
114def find_item_at(scene, pos, order=Qt.DescendingOrder, type=None,
115                 name=None):
116    """Find an object in a :class:`QGraphicsScene` `scene` at `pos`.
117    If `type` is not `None` the it must specify  the type of the item.
118    I `name` is not `None` it must be a name of the object
119    (`QObject.objectName()`).
120
121    """
122    items = scene.items(pos, Qt.IntersectsItemShape, order)
123    for item in items:
124        if type is not None and \
125                not isinstance(item, type):
126            continue
127
128        if name is not None and isinstance(item, QObject) and \
129                item.objectName() != name:
130            continue
131        return item
132    else:
133        return None
134
135
136class LinksEditScene(QGraphicsScene):
137    """A :class:`QGraphicsScene` used by the :class:`LinkEditWidget`.
138
139    """
140    def __init__(self, *args, **kwargs):
141        QGraphicsScene.__init__(self, *args, **kwargs)
142
143        self.editWidget = LinksEditWidget()
144        self.addItem(self.editWidget)
145
146    findItemAt = find_item_at
147
148
149_Link = namedtuple(
150    "_Link",
151    ["output",    # OutputSignal
152     "input",     # InputSignal
153     "lineItem",  # QGraphicsLineItem
154     ])
155
156
157class LinksEditWidget(QGraphicsWidget):
158    """A Graphics Widget for editing the links between two nodes.
159    """
160    def __init__(self, *args, **kwargs):
161        QGraphicsWidget.__init__(self, *args, **kwargs)
162        self.setAcceptedMouseButtons(Qt.LeftButton | Qt.RightButton)
163
164        self.source = None
165        self.sink = None
166
167        # QGraphicsWidget/Items in the scene.
168        self.sourceNodeWidget = None
169        self.sourceNodeTitle = None
170        self.sinkNodeWidget = None
171        self.sinkNodeTitle = None
172
173        self.__links = []
174
175        self.__textItems = []
176        self.__iconItems = []
177        self.__tmpLine = None
178        self.__dragStartItem = None
179
180        self.setLayout(QGraphicsLinearLayout(Qt.Vertical))
181        self.layout().setContentsMargins(0, 0, 0, 0)
182
183    def removeItems(self, items):
184        """Remove child items form the widget and scene.
185
186        """
187        scene = self.scene()
188        for item in items:
189            item.setParentItem(None)
190            if scene is not None:
191                scene.removeItem(item)
192
193    def clear(self):
194        """Clear the editor state (source and sink nodes, channels ...).
195
196        """
197        if self.layout().count():
198            widget = self.layout().takeAt(0).graphicsItem()
199            self.removeItems([widget])
200
201        self.source = None
202        self.sink = None
203
204    def setNodes(self, source, sink):
205        """Set the source/sink nodes (`SchemeNode` instances)
206        between which to edit the links.
207
208        """
209        self.clear()
210
211        self.source = source
212        self.sink = sink
213
214        self.__updateState()
215
216    def setLinks(self, links):
217        """Set a list of links to display between the source and sink
218        nodes. The `links` is a list of (`OutputSignal`, `InputSignal`)
219        instances where the first element refers to the source node
220        and the second to the sink node.
221
222        """
223        for output, input in links:
224            self.addLink(output, input)
225
226    def links(self):
227        """Return the links between the source and sink node.
228        """
229        return [(link.output, link.input) for link in self.__links]
230
231    def mousePressEvent(self, event):
232        if event.button() == Qt.LeftButton:
233            startItem = find_item_at(self.scene(), event.pos(),
234                                     type=ChannelAnchor)
235            if startItem is not None:
236                # Start a connection line drag.
237                self.__dragStartItem = startItem
238                self.__tmpLine = None
239                event.accept()
240                return
241
242            lineItem = find_item_at(self.scene(), event.scenePos(),
243                                    type=QGraphicsLineItem)
244            if lineItem is not None:
245                # Remove a connection under the mouse
246                for link in self.__links:
247                    if link.lineItem == lineItem:
248                        self.removeLink(link.output, link.input)
249                event.accept()
250                return
251
252        QGraphicsWidget.mousePressEvent(self, event)
253
254    def mouseMoveEvent(self, event):
255        if event.buttons() & Qt.LeftButton:
256
257            downPos = event.buttonDownPos(Qt.LeftButton)
258            if not self.__tmpLine and self.__dragStartItem and \
259                    (downPos - event.pos()).manhattanLength() > \
260                        QApplication.instance().startDragDistance():
261                # Start a line drag
262                line = QGraphicsLineItem(self)
263                start = self.__dragStartItem.boundingRect().center()
264                start = self.mapFromItem(self.__dragStartItem, start)
265                line.setLine(start.x(), start.y(),
266                             event.pos().x(), event.pos().y())
267
268                pen = QPen(Qt.green, 4)
269                pen.setCapStyle(Qt.RoundCap)
270                line.setPen(pen)
271                line.show()
272
273                self.__tmpLine = line
274
275            if self.__tmpLine:
276                # Update the temp line
277                line = self.__tmpLine.line()
278                line.setP2(event.pos())
279                self.__tmpLine.setLine(line)
280
281        QGraphicsWidget.mouseMoveEvent(self, event)
282
283    def mouseReleaseEvent(self, event):
284        if event.button() == Qt.LeftButton and self.__tmpLine:
285            endItem = find_item_at(self.scene(), event.scenePos(),
286                                     type=ChannelAnchor)
287
288            if endItem is not None:
289                startItem = self.__dragStartItem
290                startChannel = startItem.channel()
291                endChannel = endItem.channel()
292                possible = False
293                if type(startChannel) != type(endChannel):
294                    if isinstance(startChannel, InputSignal):
295                        startChannel, endChannel = endChannel, startChannel
296
297                    possible = compatible_channels(startChannel, endChannel)
298
299                if possible:
300                    self.addLink(startChannel, endChannel)
301
302            self.scene().removeItem(self.__tmpLine)
303            self.__tmpLine = None
304            self.__dragStartItem = None
305
306        QGraphicsWidget.mouseReleaseEvent(self, event)
307
308    def addLink(self, output, input):
309        if not compatible_channels(output, input):
310            return
311
312        if input.single:
313            # Remove existing link
314            for s1, s2, _ in self.__links:
315                if s2 == input:
316                    self.removeLink(s1, s2)
317                break
318
319        line = QGraphicsLineItem(self)
320
321        source_anchor = self.sourceNodeWidget.anchor(output)
322        sink_anchor = self.sinkNodeWidget.anchor(input)
323
324        source_pos = source_anchor.boundingRect().center()
325        source_pos = self.mapFromItem(source_anchor, source_pos)
326
327        sink_pos = sink_anchor.boundingRect().center()
328        sink_pos = self.mapFromItem(sink_anchor, sink_pos)
329        line.setLine(source_pos.x(), source_pos.y(),
330                     sink_pos.x(), sink_pos.y())
331        pen = QPen(Qt.green, 4)
332        pen.setCapStyle(Qt.RoundCap)
333        line.setPen(pen)
334
335        self.__links.append(_Link(output, input, line))
336
337    def removeLink(self, output, input):
338        """Remove a link between the source and sink channels.
339        """
340        for link in list(self.__links):
341            if link.output == output and link.input == input:
342                self.scene().removeItem(link.lineItem)
343                self.__links.remove(link)
344                break
345        else:
346            raise ValueError("No such link {!0.name} -> {!1.name}." \
347                             .format(output, input))
348
349    def clearLinks(self):
350        """Clear (remove) all the links.
351        """
352        for output, input, _ in list(self.__links):
353            self.removeLink(output, input)
354
355    def __updateState(self):
356        """Update the widget with the new source/sink node signal descriptions.
357
358        """
359        widget = QGraphicsWidget()
360        widget.setLayout(QGraphicsGridLayout())
361
362        # Space between left and right anchors
363        widget.layout().setHorizontalSpacing(50)
364
365        left_node = EditLinksNode(self, direction=Qt.LeftToRight,
366                                  node=self.source)
367
368        left_node.setSizePolicy(QSizePolicy.MinimumExpanding,
369                                QSizePolicy.MinimumExpanding)
370
371        right_node = EditLinksNode(self, direction=Qt.RightToLeft,
372                                   node=self.sink)
373
374        right_node.setSizePolicy(QSizePolicy.MinimumExpanding,
375                                 QSizePolicy.MinimumExpanding)
376
377        left_node.setMinimumWidth(150)
378        right_node.setMinimumWidth(150)
379
380        widget.layout().addItem(left_node, 0, 0,)
381        widget.layout().addItem(right_node, 0, 1,)
382
383        title_template = "<center><b>{0}<b></center>"
384
385        left_title = GraphicsTextWidget(self)
386        left_title.setHtml(title_template.format(escape(self.source.title)))
387        left_title.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
388
389        right_title = GraphicsTextWidget(self)
390        right_title.setHtml(title_template.format(escape(self.sink.title)))
391        right_title.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
392
393        widget.layout().addItem(left_title, 1, 0,
394                                alignment=Qt.AlignHCenter | Qt.AlignTop)
395        widget.layout().addItem(right_title, 1, 1,
396                                alignment=Qt.AlignHCenter | Qt.AlignTop)
397
398        widget.setParentItem(self)
399
400        max_w = max(left_node.sizeHint(Qt.PreferredSize).width(),
401                    right_node.sizeHint(Qt.PreferredSize).width())
402
403        # fix same size
404        left_node.setMinimumWidth(max_w)
405        right_node.setMinimumWidth(max_w)
406        left_title.setMinimumWidth(max_w)
407        right_title.setMinimumWidth(max_w)
408
409        self.layout().addItem(widget)
410        self.layout().activate()
411
412        self.sourceNodeWidget = left_node
413        self.sinkNodeWidget = right_node
414        self.sourceNodeTitle = left_title
415        self.sinkNodeTitle = right_title
416
417    if qVersion() < "4.7":
418        geometryChanged = Signal()
419
420        def setGeometry(self, rect):
421            QGraphicsWidget.setGeometry(self, rect)
422            self.geometryChanged.emit()
423
424
425class EditLinksNode(QGraphicsWidget):
426    """A Node with channel anchors.
427
428    `direction` specifies the layout (default `Qt.LeftToRight` will
429    have icon on the left and channels on the right).
430
431    """
432
433    def __init__(self, parent=None, direction=Qt.LeftToRight,
434                 node=None, icon=None, iconSize=None, **args):
435        QGraphicsWidget.__init__(self, parent, **args)
436        self.setAcceptedMouseButtons(Qt.NoButton)
437        self.__direction = direction
438
439        self.setLayout(QGraphicsLinearLayout(Qt.Horizontal))
440
441        # Set the maximum size, otherwise the layout can't grow beyond its
442        # sizeHint (and we need it to grow so the widget can grow and keep the
443        # contents centered vertically.
444        self.layout().setMaximumSize(QSizeF(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX))
445
446        self.setSizePolicy(QSizePolicy.MinimumExpanding,
447                           QSizePolicy.MinimumExpanding)
448
449        self.__iconSize = iconSize or QSize(64, 64)
450        self.__icon = icon
451
452        self.__iconItem = QGraphicsPixmapItem(self)
453        self.__iconLayoutItem = GraphicsItemLayoutItem(item=self.__iconItem)
454
455        self.__channelLayout = QGraphicsGridLayout()
456        self.__channelAnchors = []
457
458        if self.__direction == Qt.LeftToRight:
459            self.layout().addItem(self.__iconLayoutItem)
460            self.layout().addItem(self.__channelLayout)
461            channel_alignemnt = Qt.AlignRight
462
463        else:
464            self.layout().addItem(self.__channelLayout)
465            self.layout().addItem(self.__iconLayoutItem)
466            channel_alignemnt = Qt.AlignLeft
467
468        self.layout().setAlignment(self.__iconLayoutItem, Qt.AlignCenter)
469        self.layout().setAlignment(self.__channelLayout,
470                                   Qt.AlignVCenter | channel_alignemnt)
471
472        if node is not None:
473            self.setSchemeNode(node)
474
475    def setIconSize(self, size):
476        """Set the icon size for the node.
477        """
478        if size != self.__iconSize:
479            self.__iconSize = size
480            if self.__icon:
481                self.__iconItem.setPixmap(self.__icon.pixmap(size))
482                self.__iconLayoutItem.updateGeometry()
483
484    def iconSize(self):
485        return self.__iconSize
486
487    def setIcon(self, icon):
488        """Set the icon to display.
489        """
490        if icon != self.__icon:
491            self.__icon = icon
492            self.__iconItem.setPixmap(icon.pixmap(self.iconSize()))
493            self.__iconLayoutItem.updateGeometry()
494
495    def icon(self):
496        return self.__icon
497
498    def setSchemeNode(self, node):
499        """Set an instance of `SchemeNode`. The widget will be
500        initialized with its icon and channels.
501
502        """
503        self.node = node
504
505        if self.__direction == Qt.LeftToRight:
506            channels = node.output_channels()
507        else:
508            channels = node.input_channels()
509        self.channels = channels
510
511        loader = icon_loader.from_description(node.description)
512        icon = loader.get(node.description.icon)
513
514        self.setIcon(icon)
515
516        label_template = ('<div align="{align}">'
517                          '<b class="channelname">{name}</b><br/>'
518                          '<span class="typename">{typename}</span>'
519                          '</div>')
520
521        if self.__direction == Qt.LeftToRight:
522            align = "right"
523            label_alignment = Qt.AlignVCenter | Qt.AlignRight
524            anchor_alignment = Qt.AlignVCenter | Qt.AlignLeft
525            label_row = 0
526            anchor_row = 1
527        else:
528            align = "left"
529            label_alignment = Qt.AlignVCenter | Qt.AlignLeft
530            anchor_alignment = Qt.AlignVCenter | Qt.AlignLeft
531            label_row = 1
532            anchor_row = 0
533
534        self.__channelAnchors = []
535        grid = self.__channelLayout
536
537        for i, channel in enumerate(channels):
538            text = label_template.format(align=align,
539                                         name=escape(channel.name),
540                                         typename=escape(channel.type))
541
542            text_item = GraphicsTextWidget(self)
543            text_item.setHtml(text)
544            text_item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
545
546            grid.addItem(text_item, i, label_row,
547                         alignment=label_alignment)
548
549            anchor = ChannelAnchor(self, channel=channel,
550                                   rect=QRectF(0, 0, 20, 20))
551
552            anchor.setBrush(self.palette().brush(QPalette.Mid))
553
554            layout_item = GraphicsItemLayoutItem(grid, item=anchor)
555            grid.addItem(layout_item, i, anchor_row,
556                         alignment=anchor_alignment)
557
558            if hasattr(channel, "description"):
559                text_item.setToolTip((channel.description))
560
561            self.__channelAnchors.append(anchor)
562
563    def anchor(self, channel):
564        """Return the anchor item for the `channel` name.
565
566        """
567        for anchor in self.__channelAnchors:
568            if anchor.channel() == channel:
569                return anchor
570
571        raise ValueError(channel.name)
572
573    def paint(self, painter, option, widget=None):
574        painter.save()
575        palette = self.palette()
576        border = palette.brush(QPalette.Mid)
577        pen = QPen(border, 1)
578        pen.setCosmetic(True)
579        painter.setPen(pen)
580        painter.setBrush(palette.brush(QPalette.Window))
581        brect = self.boundingRect()
582        painter.drawRoundedRect(brect, 4, 4)
583        painter.restore()
584
585
586class GraphicsItemLayoutItem(QGraphicsLayoutItem):
587    """A graphics layout that handles the position of a general QGraphicsItem
588    in a QGraphicsLayout. The items boundingRect is used as this items fixed
589    sizeHint and the item is positioned at the top left corner of the this
590    items geometry.
591
592    """
593
594    def __init__(self, parent=None, item=None, ):
595        self.__item = None
596
597        QGraphicsLayoutItem.__init__(self, parent, isLayout=False)
598
599        self.setOwnedByLayout(True)
600        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
601
602        if item is not None:
603            self.setItem(item)
604
605    def setItem(self, item):
606        self.__item = item
607        self.setGraphicsItem(item)
608
609    def setGeometry(self, rect):
610        # TODO: specifiy if the geometry should be set relative to the
611        # bounding rect top left corner
612        if self.__item:
613            self.__item.setPos(rect.topLeft())
614
615        QGraphicsLayoutItem.setGeometry(self, rect)
616
617    def sizeHint(self, which, constraint):
618        if self.__item:
619            return self.__item.boundingRect().size()
620        else:
621            return QGraphicsLayoutItem.sizeHint(self, which, constraint)
622
623
624class ChannelAnchor(QGraphicsRectItem):
625    def __init__(self, parent=None, channel=None, rect=None, **kwargs):
626        QGraphicsRectItem.__init__(self, **kwargs)
627        self.setAcceptHoverEvents(True)
628        self.setAcceptedMouseButtons(Qt.NoButton)
629        self.__channel = None
630
631        if rect is None:
632            rect = QRectF(0, 0, 20, 20)
633
634        self.setRect(rect)
635
636        if channel:
637            self.setChannel(channel)
638
639        self.__shadow = QGraphicsDropShadowEffect(blurRadius=5,
640                                                  offset=QPointF(0, 0))
641        self.setGraphicsEffect(self.__shadow)
642        self.__shadow.setEnabled(False)
643
644    def setChannel(self, channel):
645        if channel != self.__channel:
646            self.__channel = channel
647            if hasattr(channel, "description"):
648                self.setToolTip(channel.description)
649            # TODO: Should also include name, type, flags, dynamic in the
650            #       tool tip as well as add visual clues to the anchor
651
652    def channel(self):
653        return self.__channel
654
655    def hoverEnterEvent(self, event):
656        self.__shadow.setEnabled(True)
657        QGraphicsRectItem.hoverEnterEvent(self, event)
658
659    def hoverLeaveEvent(self, event):
660        self.__shadow.setEnabled(False)
661        QGraphicsRectItem.hoverLeaveEvent(self, event)
662
663
664class GraphicsTextWidget(QGraphicsWidget):
665    """A QGraphicsWidget subclass that manages a QGraphicsTextItem
666
667    """
668
669    def __init__(self, parent=None, textItem=None):
670        QGraphicsLayoutItem.__init__(self, parent)
671        if textItem is None:
672            textItem = QGraphicsTextItem()
673
674        self.__textItem = textItem
675        self.__textItem.setParentItem(self)
676        self.__textItem.setPos(0, 0)
677
678        doc_layout = self.document().documentLayout()
679        doc_layout.documentSizeChanged.connect(self._onDocumentSizeChanged)
680
681    def sizeHint(self, which, constraint=QSizeF()):
682        # TODO: More sensible size hints.
683        # If the text is a plain text or html
684        # Check how QLabel.sizeHint works.
685
686        if which == Qt.PreferredSize:
687            return self.__textItem.boundingRect().size()
688        else:
689            return QGraphicsWidget.sizeHint(self, which, constraint)
690
691    def setGeometry(self, rect):
692        QGraphicsWidget.setGeometry(self, rect)
693        self.__textItem.setTextWidth(rect.width())
694
695    def setPlainText(self, text):
696        self.__textItem.setPlainText(text)
697        self.updateGeometry()
698
699    def setHtml(self, text):
700        self.__textItem.setHtml(text)
701
702    def adjustSize(self):
703        self.__textItem.adjustSize()
704        self.updateGeometry()
705
706    def setDefaultTextColor(self, color):
707        self.__textItem.setDefaultTextColor(color)
708
709    def document(self):
710        return self.__textItem.document()
711
712    def setDocument(self, doc):
713        doc_layout = self.document().documentLayout()
714        doc_layout.documentSizeChanged.disconnect(self._onDocumentSizeChanged)
715
716        self.__textItem.setDocument(doc)
717
718        doc_layout = self.document().documentLayout()
719        doc_layout.documentSizeChanged.connect(self._onDocumentSizeChanged)
720
721        self.updateGeometry()
722
723    def _onDocumentSizeChanged(self, size):
724        """The doc size has changed"""
725        self.updateGeometry()
Note: See TracBrowser for help on using the repository browser.