source: orange/Orange/OrangeCanvas/canvas/editlinksdialog.py @ 11109:997e0b998139

Revision 11109:997e0b998139, 23.1 KB checked in by Ales Erjavec <ales.erjavec@…>, 19 months ago (diff)

Added EditLinksDialog class (edit links between two widgets).

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