Connectors.py 22.4 KB
Newer Older
Maxime Perrotin's avatar
Maxime Perrotin committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
    Diagram elements for connectors. Different types of connectors are
    available by default, such as lines, arrows, and bezier curves.

    The "Connection" class is the mother class, all other connectors must
    inherit from it and possibly redefine some functions or shape.

    Copyright (c) 2012-2014 European Space Agency

    Designed and implemented by Maxime Perrotin

    Contact: maxime.perrotin@esa.int
"""

import math
import logging

21
from PySide.QtCore import Qt, QPointF, QLineF, Slot
Maxime Perrotin's avatar
Maxime Perrotin committed
22

23
24
25
from PySide.QtGui import(QGraphicsPathItem, QPainterPath, QGraphicsItem, QPen,
                         QPainter, QFont, QGraphicsTextItem, QColor,
                         QFontMetrics, QTextBlockFormat, QTextCursor)
Maxime Perrotin's avatar
Maxime Perrotin committed
26

27
28
29
import ogParser
from TextInteraction import EditableText

Maxime Perrotin's avatar
Maxime Perrotin committed
30
31
32
LOG = logging.getLogger(__name__)


Maxime Perrotin's avatar
Maxime Perrotin committed
33
34
35
36
37
38
39
40
41
42
43
44
# pylint: disable=R0904
class Connection(QGraphicsPathItem, object):
    ''' Connection between two symbols (top-level class) '''
    # Default connections are not selectable
    default_cursor = Qt.ArrowCursor
    hasParent = False

    def __init__(self, parent, child):
        ''' Create a new connection between a parent and a child item '''
        super(Connection, self).__init__(parent)
        self.parent = parent
        self.child = child
Maxime Perrotin's avatar
Maxime Perrotin committed
45
        self._start_point = None
46
47
        self._end_point = None
        self._middle_points = []
Maxime Perrotin's avatar
Maxime Perrotin committed
48
49
50
51
52
53
54
55
56
        pen = QPen()
        pen.setColor(Qt.blue)
        pen.setCosmetic(False)
        self.setPen(pen)
        self.parent_rect = parent.sceneBoundingRect()
        self.childRect = child.sceneBoundingRect()
        # Activate cache mode to boost rendering by calling paint less often
        self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)

57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
    @property
    def start_point(self):
        ''' Compute connection origin - redefine in subclasses '''
        return self._start_point or QPointF(0, 0)

    @property
    def end_point(self):
        ''' Compute connection end point - redefine in subclasses '''
        return self._end_point or QPointF(0, 0)

    @property
    def middle_points(self):
        ''' Compute connection intermediate points - redefine in subclasses '''
        return self._middle_points

72
73
74
75
76
    def top_level(self):
        ''' In case the connection has a text, implement top_level()
            as needed by TextInteraction '''
        return self.parent

Maxime Perrotin's avatar
Maxime Perrotin committed
77
78
79
80
81
82
    def simple_arrow(self, origin='head', path=None):
        ''' Compute the two points of an vertical arrow head '''
        if origin == 'head':
            endp = self.end_point
        else:
            endp = self.start_point
83
84
85
        return (QPointF(endp.x() - 5, endp.y() - 5),
                QPointF(endp.x() + 5, endp.y() - 5))

86
87
88
89
    def select(self):
        ''' Select a connection - for future use? '''
        return

Maxime Perrotin's avatar
Maxime Perrotin committed
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
    def angle_arrow(self, path, origin='head'):
        ''' Compute the two points of the arrow head with the right angle '''
        if origin == 'tail':
            path = path.toReversed()
        length = path.length()
        percent = path.percentAtLength(length - 10.0)
        src = path.pointAtPercent(percent)
        #path.moveTo(path.pointAtPercent(1))
        end_point = path.pointAtPercent(1)
        #end_point = path.currentPosition()
        line = QLineF(src, end_point)
        angle = math.acos(line.dx() / (line.length() or 1))
        if line.dy() >= 0:
            angle = math.pi * 2 - angle
        arrow_size = 10.0
        arrow_p1 = end_point + QPointF(
Maxime Perrotin's avatar
Maxime Perrotin committed
106
107
                math.sin(angle - math.pi / 3) * arrow_size,
                math.cos(angle - math.pi / 3) * arrow_size)
Maxime Perrotin's avatar
Maxime Perrotin committed
108
        arrow_p2 = end_point + QPointF(
Maxime Perrotin's avatar
Maxime Perrotin committed
109
110
                math.sin(angle - math.pi + math.pi / 3) * arrow_size,
                math.cos(angle - math.pi + math.pi / 3) * arrow_size)
Maxime Perrotin's avatar
Maxime Perrotin committed
111
112
113
114
115
116
117
118
        return (arrow_p1, arrow_p2)

    def draw_arrow_head(self, shape, origin='head', kind='simple'):
        ''' Generic function to draw a simple arrow '''
        if kind == 'simple':
            arrowhead = self.simple_arrow(path=shape)
        else:
            arrowhead = self.angle_arrow(shape, origin)
119
        shape.lineTo(arrowhead[0])
Maxime Perrotin's avatar
Maxime Perrotin committed
120
        shape.moveTo(self.end_point if origin == 'head' else self.start_point)
121
122
        shape.lineTo(arrowhead[1])

Maxime Perrotin's avatar
Maxime Perrotin committed
123
124
125
126
127
128
129
    def __str__(self):
        ''' Print connection information for debug purpose'''
        return 'Connection: parent = {p}, child = {c}'.format(
                p=str(self.parentItem()), c=str(self.child))

    def reshape(self):
        ''' Update the connection or arrow shape '''
130
131
132
133
134
135
136
        shape = QPainterPath()
        shape.moveTo(self.start_point)
        for point in self.middle_points:
            shape.lineTo(point)
        shape.lineTo(self.end_point)
        # If required draw an arrow head (e.g. in SDL NEXTSTATE and JOIN)
        if self.child.arrow_head:
Maxime Perrotin's avatar
Maxime Perrotin committed
137
138
139
140
141
142
            self.draw_arrow_head(shape, origin='head',
                                 kind=self.child.arrow_head)
        if self.child.arrow_tail:
            shape.moveTo(shape.pointAtPercent(0))
            self.draw_arrow_head(shape, origin='tail',
                                 kind=self.child.arrow_head)
143
        self.setPath(shape)
Maxime Perrotin's avatar
Maxime Perrotin committed
144
145
146
147


class RakeConnection(Connection):
    ''' Fork-like connection, e.g. between a state and an input symbol '''
148
149
150
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
151
        parent_rect = self.parentItem().boundingRect()
152
153
154
155
156
157
158
159
160
161
162
163
164
165
        return QPointF(parent_rect.width() / 2, parent_rect.height())

    @property
    def end_point(self):
        ''' Compute connection end point - redefined function '''
        coord = self.child.pos()
        coord.setX(coord.x() + self.child.boundingRect().width() / 2)
        return coord

    @property
    def middle_points(self):
        ''' Compute connection intermediate points - redefined function '''
        yield QPointF(self.start_point.x(), self.start_point.y() + 10)
        yield QPointF(self.end_point.x(), self.start_point.y() + 10)
Maxime Perrotin's avatar
Maxime Perrotin committed
166
167
168
169
170


class JoinConnection(Connection):
    ''' Inverted fork-like connection, to join to a common point '''
    def reshape(self):
171
172
        ''' Update the connection shape - redefined: if the last element
        of a branch is e.g. a nextstate, don't join the connection point '''
Maxime Perrotin's avatar
Maxime Perrotin committed
173
        if self.parentItem().terminal_symbol:
174
            self.setPath(QPainterPath())
Maxime Perrotin's avatar
Maxime Perrotin committed
175
        else:
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
            super(JoinConnection, self).reshape()

    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
        parent_rect = self.parentItem().boundingRect()
        return getattr(self.parentItem(), 'connectionPoint',
                       QPointF(parent_rect.width() / 2, parent_rect.height()))

    @property
    def end_point(self):
        ''' Compute connection end point - redefined function '''
        point_scene = self.child.mapToScene(self.child.connectionPoint)
        return self.mapFromScene(point_scene)

    @property
    def middle_points(self):
        ''' Compute connection intermediate points - redefined function '''
        yield QPointF(self.start_point.x(), self.end_point.y() - 10)
        yield QPointF(self.end_point.x(), self.end_point.y() - 10)
Maxime Perrotin's avatar
Maxime Perrotin committed
196
197
198
199


class VerticalConnection(Connection):
    ''' Vertical line with or without arrow '''
200
201
202
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
203
        parent_rect = self.parentItem().boundingRect()
204
205
206
207
208
209
210
211
212
        return getattr(self.parentItem(), 'connectionPoint',
                       QPointF(parent_rect.width() / 2, parent_rect.height()))

    @property
    def end_point(self):
        ''' Compute connection end point - redefined function '''
        point = self.child.pos()
        point.setX(self.start_point.x())
        return point
Maxime Perrotin's avatar
Maxime Perrotin committed
213
214
215
216
217


class CommentConnection(Connection):
    ''' Class handling connection to comment symbols, fixed at the right
        of a symbol '''
218
219
220
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
221
        parent_rect = self.parentItem().boundingRect()
222
223
224
225
226
        return QPointF(parent_rect.width(), parent_rect.height() / 2)

    @property
    def end_point(self):
        ''' Compute connection end point - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
227
        if self.child.on_the_right:
228
229
            return QPointF(self.child.x(),
                   self.child.y() + self.child.boundingRect().height() / 2)
Maxime Perrotin's avatar
Maxime Perrotin committed
230
        else:
231
232
233
234
235
236
            return QPointF(self.child.x() + self.child.boundingRect().width(),
                   self.child.y() + self.child.boundingRect().height() / 2)

    @property
    def middle_points(self):
        ''' Compute connection intermediate points - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
237
238
239
240
241
242
243
244
        # Make sure the connection does not overlap the comment item
        if (self.child.on_the_right or
               (not self.child.on_the_right and
               self.child.x() + self.child.boundingRect().width()
               < self.parentItem().boundingRect().width())):
            go_to_point = self.start_point.x() + 5
        else:
            go_to_point = self.end_point.x() + 5
245
246
        yield QPointF(go_to_point, self.start_point.y())
        yield QPointF(go_to_point, self.end_point.y())
Maxime Perrotin's avatar
Maxime Perrotin committed
247
248


249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
class SignalList(EditableText):
    ''' Simplified text editor for signal lists '''
    def __init__(self, parent, text='', hyperlink = None):
        ''' Smaller font than normal text '''
        super(SignalList, self).__init__(parent, text)
        self.setFont(QFont('Ubuntu', pointSize=8))

    def set_text_alignment(self):
        ''' Text justification - ignore '''
        pass

    def set_textbox_position(self):
        ''' Alignment vs parent - ignore, done by the parent '''
        pass

    def try_resize(self):
265
        ''' Resizing of the parent when size expands - ignore '''
266
267
        pass

268
269
270
271
272
    # pylint: disable=C0103
    def focusOutEvent(self, event):
        ''' Redefined function - update in_sig and out_sig in parent '''
        super(SignalList, self).focusOutEvent(event)

273
274

class Signalroute(Connection):
Maxime Perrotin's avatar
Maxime Perrotin committed
275
    ''' Subclass of Connection used to draw channels between processes '''
276
    in_sig = out_sig = None
277
278
    completion_list = set()

Maxime Perrotin's avatar
Maxime Perrotin committed
279
    def __init__(self, process):
Maxime Perrotin's avatar
Maxime Perrotin committed
280
        ''' Set generic parameters from Connection class '''
281
282
283
284
285
286
287
        super(Signalroute, self).__init__(process, process)
        self.parser = ogParser
        self.blackbold = ()
        self.redbold = ()
        self.label_in = SignalList(parent=self)
        self.label_out = SignalList(parent=self)
        if not Signalroute.in_sig:
288
            # keep at class level as long as only one process is supported
Maxime Perrotin's avatar
Typo    
Maxime Perrotin committed
289
            # when copy-pasting a process the channel in/out signal lists
290
291
292
293
            # are not parsed. Workaround is to keep the list "global"
            # to allow a copy of both process and channel
            # Needed for the image exporter, that copies the scene to a
            # temporary one
294
            Signalroute.in_sig = '{}'.format(',\n'.join(sig['name']
295
                                   for sig in process.input_signals))
296
            Signalroute.out_sig = '{}'.format(',\n'.join(sig['name']
297
                                    for sig in process.output_signals))
298
299
        self.label_in.setPlainText('[{}]'.format(self.in_sig))
        self.label_out.setPlainText('[{}]'.format(self.out_sig))
300
301
        self.label_in.document().contentsChanged.connect(self.change_siglist)
        self.label_out.document().contentsChanged.connect(self.change_siglist)
302
303
        for each in (self.label_in, self.label_out):
            each.show()
Maxime Perrotin's avatar
Maxime Perrotin committed
304
        self.process = process
305
        self.reshape()
Maxime Perrotin's avatar
Maxime Perrotin committed
306

307
308
309
310
311
312
313
314
315
316
317
318
319
    @Slot()
    def change_siglist(self):
        ''' Called when user modified the text of label_in or label_out '''
        for each in self.label_in, self.label_out:
            if not each.hasFocus():
                if not unicode(each).startswith('['):
                    each.setPlainText('[{}'.format(unicode(each)))
                if not unicode(each).endswith(']'):
                    each.setPlainText('{}]'.format(unicode(each)))
        Signalroute.in_sig = unicode(self.label_in)[1:-1]
        Signalroute.out_sig = unicode(self.label_out)[1:-1]


Maxime Perrotin's avatar
Maxime Perrotin committed
320
321
322
323
324
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
        parent_rect = self.process.boundingRect()
        return QPointF(parent_rect.x(), parent_rect.height() / 2)
Maxime Perrotin's avatar
Maxime Perrotin committed
325

Maxime Perrotin's avatar
Maxime Perrotin committed
326
327
328
329
    @property
    def end_point(self):
        ''' Compute connection end point - redefined function '''
        # Arrow always bumps at the screen edge
330
331
332
        try:
            view = self.scene().views()[0]
            view_pos = view.mapToScene(
Maxime Perrotin's avatar
Maxime Perrotin committed
333
                           view.viewport().geometry()).boundingRect().topLeft()
334
335
            scene_pos_x = self.mapFromScene(view_pos).x()
            return QPointF(scene_pos_x, self.start_point.y())
336
        except (IndexError, AttributeError):
337
            # In case there is no view (e.g. Export PNG from cmd line)
338
339
340
341
            return QPointF(self.start_point.x() - 250, self.start_point.y())

    def reshape(self):
        ''' Redefine shape function to add the text areas '''
342
        super(Signalroute, self).reshape()
343
344
345
346
347
348
349

        width_in = self.label_in.boundingRect().width()

        self.label_in.setX(self.start_point.x() - width_in)
        self.label_in.setY(self.start_point.y() + 5)
        self.label_out.setX(self.end_point.x() + 10)
        self.label_out.setY(self.end_point.y() + 5)
Maxime Perrotin's avatar
Maxime Perrotin committed
350

351
352
353
354
355
356
357
358
359
360
361
362
    def check_syntax(self, text):
        ''' Check the syntax of the IN and OUT signal lists '''
        try:
            _, syntax_errors, _, _, _ = self.parser.parseSingleElement(
                                           'signalroute', text)
        except (AssertionError, AttributeError) as err:
            LOG.error('Checker failed:' + str(err))
        else:
            return syntax_errors

    def update_completion_list(self, pr_text):
        ''' Called after text has been edited '''
363
        # TODO - call parseSingleElement, check sdlSymbols for examples
364
        pass
365
366
367
368
369

    def resize_item(self, new_rect):
        ''' Called after signallist text has been edited '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450

class Controlpoint(QGraphicsPathItem, object):
    ''' Class handling one edge control point (to change bezier curves) '''
    def __init__(self, pos, edge):
        ''' Set the original control point - with color, shape '''
        path = QPainterPath()
        path.addEllipse(pos.x() - 5, pos.y() - 5, 10, 10)
        super(Controlpoint, self).__init__(path, parent=edge)
        self.setPen(QColor(50, 100, 120, 200))
        self.setBrush(QColor(200, 200, 210, 120))
        self.setFlags(QGraphicsItem.ItemIsSelectable |
                      QGraphicsItem.ItemIsMovable)
        self.edge = edge
        self.hide()

    @property
    def center(self):
        ''' Return the position of the center of the shape '''
        return self.pos() + self.boundingRect().center()

    def mouseMoveEvent(self, event):
        ''' When user moves a control point, update the connection shape '''
        super(Controlpoint, self).mouseMoveEvent(event)
        self.edge.reshape()


class Connectionpoint(Controlpoint):
    '''
        Class handling an end connection point - similar to Controlpoint,
        except the shape and the behaviour when moved. Connection point
        must check with its owning symbol if the move is valid.
    '''
    def __init__(self, pos, edge, symbol):
        ''' Create the point - as a small, lightblue box '''
        super(Connectionpoint, self).__init__(pos, edge=edge)
        path = QPainterPath()
        path.addRect(0, 0, 10, 10)
        self.setPath(path)
        self.setPos(pos.x() - 5, pos.y() - 5)
        # Symbol actually owning the connection point
        self.symbol = symbol

    def update_position(self):
        '''
            Update the position of the end point so that it is always
            placed correctly on the symbol that contains it (but that may
            not be the parent of the point if is the end point)
            After the position update, reshape the edge so that the lines
            are properly drawn to the new position
        '''
        # Transform position to owning symbol coordinates:
        center_pos = self.scenePos() + self.boundingRect().center()
        pos_symb = self.symbol.mapFromScene(center_pos)

        # Ask to symbol the nearest valid position on its shape
        # to place the connection point, and adjust position
        nearest_x, nearest_y = self.symbol.closest_connection_point(pos_symb)

        self.moveBy(-nearest_x, -nearest_y)

        self.edge.reshape()

    def mouseMoveEvent(self, event):
        '''
            When user moves the point, check with the connected symbol
            where the actual connection point should be placed
        '''
        # calling super function moves to the new point
        super(Connectionpoint, self).mouseMoveEvent(event)
        # Then compute a valid position, based on the owning symbol
        self.update_position()


class Edge(Connection):
    ''' B-spline/Bezier connection shape '''
    def __init__(self, edge, graph):
        ''' Set generic parameters from Connection class '''
        self.text_label = None
        super(Edge, self).__init__(edge['source'], edge['target'])
        self.edge = edge
        self.graph = graph
451
452
        # Set connection points as not visible, by default
        self.bezier_visible = False
Maxime Perrotin's avatar
Maxime Perrotin committed
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471

        # Initialize control point coordinates
        self.bezier = [self.mapFromScene(*self.edge['spline'][0])]
        # Bezier control points (groups of three points):
        assert(len(self.edge['spline']) % 3 == 1)
        for i in xrange(1, len(self.edge['spline']), 3):
            self.bezier.append([Controlpoint(
                          self.mapFromScene(*self.edge['spline'][i + j]), self)
                          for j in range(3)])

        # Create connection points at start and end of the edge
        self.source_connection = Connectionpoint(
                self.start_point or self.bezier[0], self, self.parent)
        self.parent.movable_points.append(self.source_connection)
        self.end_connection = Connectionpoint(
                self.end_point or self.bezier[-1], self, self.child)
        self.child.movable_points.append(self.end_connection)
        self.reshape()

472
473
474
475
476
477
478
479
480
481
482
483
484
    @property
    def start_point(self):
        ''' Compute connection origin - redefine in subclasses '''
        # Start point is optional - graphviz decision
        return self.mapFromScene(*self.edge['start']) \
               if self.edge.get('start') else None

    @property
    def end_point(self):
        ''' Compute connection end point - redefine in subclasses '''
        return self.mapFromScene(*self.edge['end']) \
               if self.edge.get('end') else None

Maxime Perrotin's avatar
Maxime Perrotin committed
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
    def bezier_set_visible(self, visible=True):
        ''' Display or hide the edge control points '''
        self.bezier_visible = visible
        for group in self.bezier[1:]:
            for ctrl_point in group:
                if visible:
                    ctrl_point.show()
                else:
                    ctrl_point.hide()
        if visible:
            self.end_connection.show()
            self.source_connection.show()
        else:
            self.end_connection.hide()
            self.source_connection.hide()
        self.update()

    def mousePressEvent(self, event):
        ''' On a mouse click, display the control points '''
        self.bezier_set_visible(True)

506
    # pylint: disable=R0914
Maxime Perrotin's avatar
Maxime Perrotin committed
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
    def reshape(self):
        ''' Update the shape of the edge (redefined function) '''
        path = QPainterPath()
        # If there is a starting point, draw a line to the first curve point
        if self.start_point:
            path.moveTo(self.source_connection.center)
            path.lineTo(self.bezier[0])
        else:
            path.moveTo(self.source_connection.center)
        # Loop over the curve points:
        for group in self.bezier[1:]:
            path.cubicTo(*[point.center for point in group])

        # If there is an ending point, draw a line to it
        if self.end_point:
            path.lineTo(self.end_connection.center)

        end_point = path.currentPosition()
Maxime Perrotin's avatar
Maxime Perrotin committed
525
        arrowhead = self.angle_arrow(path)
526
527
528
        path.lineTo(arrowhead[0])
        path.moveTo(end_point)
        path.lineTo(arrowhead[1])
Maxime Perrotin's avatar
Maxime Perrotin committed
529
530
531
532
        path.moveTo(end_point)
        try:
            # Add the transition label, if any (none for the START edge)
            font = QFont('arial', pointSize=8)
533
            metrics = QFontMetrics(font)
534
            label = self.edge.get('label', '') or ''
535
536
537
538
            lines = label.split('\n')
            width = metrics.width(max(lines)) # longest line
            height = metrics.height() * len(lines)
            # lp is the position of the center of the text
Maxime Perrotin's avatar
Maxime Perrotin committed
539
540
541
542
            pos = self.mapFromScene(*self.edge['lp'])
            if not self.text_label:
                self.text_label = QGraphicsTextItem(
                                 self.edge.get('label', ''), parent=self)
543
544
545
546
547
548
549
550
551
552
553
554
555
556
                self.text_label.setX(pos.x() - width / 2)
                self.text_label.setY(pos.y() - height / 2)
                self.text_label.setFont(font)
                # Make horizontal center alignment, as dot does
                self.text_label.setTextWidth(
                        self.text_label.boundingRect().width())
                fmt = QTextBlockFormat()
                fmt.setAlignment(Qt.AlignHCenter)
                cursor = self.text_label.textCursor()
                cursor.select(QTextCursor.Document)
                cursor.mergeBlockFormat(fmt)
                cursor.clearSelection()
                self.text_label.setTextCursor(cursor)
                self.text_label.show()
Maxime Perrotin's avatar
Maxime Perrotin committed
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
        except KeyError:
            # no label
            pass
        self.setPath(path)

    def __str__(self):
        ''' user-friendly information about the edge coordinates '''
        return('Edge between ' + self.edge['source'].name + ' and ' +
                self.edge['target'].name + str(self.edge['spline'][0]))

    def paint(self, painter, option, widget):
        ''' Apply anti-aliasing to Edge Connections '''
        painter.setRenderHint(QPainter.Antialiasing, True)
        super(Edge, self).paint(painter, option, widget)
        # Draw lines between connection points, if visible
        if self.bezier_visible:
            painter.setPen(
                    QPen(Qt.lightGray, 0, Qt.SolidLine))
            painter.setBrush(Qt.NoBrush)
            points_flat = [point.center
                           for sub1 in self.bezier[1:] for point in sub1]
            painter.drawPolyline([self.source_connection.center]
                                  + points_flat + [self.end_connection.center])