Connectors.py 26.5 KB
Newer Older
Maxime Perrotin's avatar
Maxime Perrotin committed
1
2
3
4
5
6
7
8
9
10
#!/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.

11
    Copyright (c) 2012-2020 European Space Agency
Maxime Perrotin's avatar
Maxime Perrotin committed
12
13
14
15
16
17
18
19
20

    Designed and implemented by Maxime Perrotin

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

import math
import logging

Maxime Perrotin's avatar
Maxime Perrotin committed
21
22
23
24
25
from PySide2.QtCore import *
from PySide2.QtGui import *
from PySide2.QtWidgets import *
from . import ogParser
from .TextInteraction import EditableText
26

Maxime Perrotin's avatar
Maxime Perrotin committed
27
28
29
LOG = logging.getLogger(__name__)


Maxime Perrotin's avatar
Maxime Perrotin committed
30
# pylint: disable=R0904
Maxime Perrotin's avatar
Maxime Perrotin committed
31
class Connection(QGraphicsPathItem):
Maxime Perrotin's avatar
Maxime Perrotin committed
32
33
34
35
36
37
38
39
40
41
    ''' 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
42
        self._start_point = None
43
44
        self._end_point = None
        self._middle_points = []
Maxime Perrotin's avatar
Maxime Perrotin committed
45
46
47
48
49
50
51
52
        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)
53
54
        # When the child moves, the connection may need to adjust the end point
        self.child.moved.connect(self.child_moved)
55
56
        # Syntax error indicator
        self.syntax_error: Boolean = False
57
58
59
60
61

    @Slot(float, float)
    def child_moved(self, delta_x, delta_y):
        ''' When the connection child moves - redefine in subclasses '''
        pass
Maxime Perrotin's avatar
Maxime Perrotin committed
62

63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
    @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

78
79
80
81
82
    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
83
84
85
86
87
88
    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
89
90
91
        return (QPointF(endp.x() - 5, endp.y() - 5),
                QPointF(endp.x() + 5, endp.y() - 5))

Maxime Perrotin's avatar
Maxime Perrotin committed
92
    def select(self, selected=True):
93
94
95
        ''' Select a connection - for future use? '''
        return

Maxime Perrotin's avatar
Maxime Perrotin committed
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
    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
112
113
                math.sin(angle - math.pi / 3) * arrow_size,
                math.cos(angle - math.pi / 3) * arrow_size)
Maxime Perrotin's avatar
Maxime Perrotin committed
114
        arrow_p2 = end_point + QPointF(
Maxime Perrotin's avatar
Maxime Perrotin committed
115
116
                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
117
118
119
120
121
122
123
124
        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)
125
        shape.lineTo(arrowhead[0])
Maxime Perrotin's avatar
Maxime Perrotin committed
126
        shape.moveTo(self.end_point if origin == 'head' else self.start_point)
127
128
        shape.lineTo(arrowhead[1])

Maxime Perrotin's avatar
Maxime Perrotin committed
129
130
131
132
133
134
135
    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 '''
136
137
138
139
140
141
142
        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
143
144
145
146
147
148
            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)
149
        self.setPath(shape)
Maxime Perrotin's avatar
Maxime Perrotin committed
150
151
152
153


class RakeConnection(Connection):
    ''' Fork-like connection, e.g. between a state and an input symbol '''
154
155
156
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
157
        parent_rect = self.parentItem().boundingRect()
158
159
160
161
162
163
164
165
166
167
168
169
170
171
        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
172
173
174
175
176


class JoinConnection(Connection):
    ''' Inverted fork-like connection, to join to a common point '''
    def reshape(self):
177
178
        ''' 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
179
        if self.parentItem().terminal_symbol:
180
            self.setPath(QPainterPath())
Maxime Perrotin's avatar
Maxime Perrotin committed
181
        else:
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
            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
202
203
204
205


class VerticalConnection(Connection):
    ''' Vertical line with or without arrow '''
206
207
208
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
209
        parent_rect = self.parentItem().boundingRect()
210
211
212
213
214
215
216
217
218
        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
219
220
221
222
223


class CommentConnection(Connection):
    ''' Class handling connection to comment symbols, fixed at the right
        of a symbol '''
224
225
226
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
227
        parent_rect = self.parentItem().boundingRect()
228
229
230
231
232
        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
233
        if self.child.on_the_right:
234
235
            return QPointF(self.child.x(),
                   self.child.y() + self.child.boundingRect().height() / 2)
Maxime Perrotin's avatar
Maxime Perrotin committed
236
        else:
237
238
239
240
241
242
            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
243
244
245
246
247
248
249
250
        # 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
251
252
        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
253
254


255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
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):
271
        ''' Resizing of the parent when size expands - ignore '''
272
273
        pass

274
275
276
277
278
    # pylint: disable=C0103
    def focusOutEvent(self, event):
        ''' Redefined function - update in_sig and out_sig in parent '''
        super(SignalList, self).focusOutEvent(event)

279
280

class Signalroute(Connection):
Maxime Perrotin's avatar
Maxime Perrotin committed
281
    ''' Subclass of Connection used to draw channels between processes '''
282
    in_sig = out_sig = None
283
284
    completion_list = set()

Maxime Perrotin's avatar
Maxime Perrotin committed
285
    def __init__(self, parent, child=None):
Maxime Perrotin's avatar
Maxime Perrotin committed
286
        ''' Set generic parameters from Connection class '''
Maxime Perrotin's avatar
Maxime Perrotin committed
287
        super(Signalroute, self).__init__(parent, child or parent)
288
289
290
291
292
293
        self.parser = ogParser
        self.blackbold = ()
        self.redbold = ()
        self.label_in = SignalList(parent=self)
        self.label_out = SignalList(parent=self)
        if not Signalroute.in_sig:
294
            # keep at class level as long as only one process is supported
Maxime Perrotin's avatar
Typo    
Maxime Perrotin committed
295
            # when copy-pasting a process the channel in/out signal lists
296
297
298
299
            # 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
300
            Signalroute.in_sig = '{}'.format(',\n'.join(sig['name']
Maxime Perrotin's avatar
Maxime Perrotin committed
301
                                   for sig in parent.input_signals))
302
            Signalroute.out_sig = '{}'.format(',\n'.join(sig['name']
Maxime Perrotin's avatar
Maxime Perrotin committed
303
                                    for sig in parent.output_signals))
304
305
        self.label_in.setPlainText('[{}]'.format(self.in_sig))
        self.label_out.setPlainText('[{}]'.format(self.out_sig))
306
307
        self.label_in.document().contentsChanged.connect(self.change_siglist)
        self.label_out.document().contentsChanged.connect(self.change_siglist)
308
309
        for each in (self.label_in, self.label_out):
            each.show()
310
        #self.reshape()
Maxime Perrotin's avatar
Maxime Perrotin committed
311

312
313
314
315
316
    @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():
Maxime Perrotin's avatar
Maxime Perrotin committed
317
318
319
320
321
322
                if not str(each).startswith('['):
                    each.setPlainText('[{}'.format(str(each)))
                if not str(each).endswith(']'):
                    each.setPlainText('{}]'.format(str(each)))
        Signalroute.in_sig = str(self.label_in)[1:-1]
        Signalroute.out_sig = str(self.label_out)[1:-1]
323
324


Maxime Perrotin's avatar
Maxime Perrotin committed
325
326
327
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
328
        parent_rect = self.parent.boundingRect()
Maxime Perrotin's avatar
Maxime Perrotin committed
329
        return QPointF(parent_rect.x(), parent_rect.height() / 2)
Maxime Perrotin's avatar
Maxime Perrotin committed
330

Maxime Perrotin's avatar
Maxime Perrotin committed
331
332
333
334
    @property
    def end_point(self):
        ''' Compute connection end point - redefined function '''
        # Arrow always bumps at the screen edge
335
336
        try:
            view = self.scene().views()[0]
Maxime Perrotin's avatar
Maxime Perrotin committed
337
338
            #view.update_phantom_rect()
            # view_pos is the position of the view relative to the scene
339
            view_pos = view.mapToScene(
Maxime Perrotin's avatar
Maxime Perrotin committed
340
                           view.viewport().geometry()).boundingRect().topLeft()
341
            scene_pos_x = self.mapFromScene(view_pos).x()
Maxime Perrotin's avatar
Maxime Perrotin committed
342
            #print view_pos.x(), scene_pos_x, view.sceneRect().x()
343
            return QPointF(scene_pos_x, self.start_point.y())
344
        except (IndexError, AttributeError):
345
            # In case there is no view (e.g. Export PNG from cmd line)
346
347
348
349
            return QPointF(self.start_point.x() - 250, self.start_point.y())

    def reshape(self):
        ''' Redefine shape function to add the text areas '''
350
        super(Signalroute, self).reshape()
351
352
353
354
355
356
357

        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
358

359
360
361
362
363
364
365
366
367
368
369
370
    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 '''
Maxime Perrotin's avatar
Maxime Perrotin committed
371
        from .sdlSymbols import CONTEXT
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
        ast, _, _, _, _ = self.parser.parseSingleElement('signalroute',
                                                         pr_text)
        # ast is a dict:
        # {'routes': [{'dest': str, 'source': str', 'signals': [str]}],
        #  'name': u'c'} - dest and source can be 'ENV'
        all_sigs = []
        for each in ast['routes']:
            source    = each['source']
            dest      = each['dest']
            sigs = [{'name': name} for name in each['signals']]
            all_sigs.extend(sigs)
            def get_sig(signal):
                ''' If signal is declared, return the signature '''
                for sig in CONTEXT.signals:
                    if sig['name'].lower() == signal['name'].lower():
                        return sig
                return signal
            if each['dest'] == 'ENV':
                # output signals of process 'source'
                process, = [p for p in CONTEXT.processes
                            if p.processName.lower() == each['source'].lower()]
                existing = [sig['name'].lower()
                           for sig in process.output_signals]
                for sig in sigs:
                    if sig['name'].lower() not in existing:
                        process.output_signals.append(get_sig(sig))
            else:
                # input signals of process 'source'
Maxime Perrotin's avatar
Maxime Perrotin committed
400
401
                try:
                    process, = [p for p in CONTEXT.processes
402
                            if p.processName.lower() == each['dest'].lower()]
Maxime Perrotin's avatar
Maxime Perrotin committed
403
                    existing = [sig['name'].lower()
404
                            for sig in process.input_signals]
Maxime Perrotin's avatar
Maxime Perrotin committed
405
406
407
408
409
410
                    for sig in sigs:
                        if sig['name'].lower() not in existing:
                            process.input_signals.append(get_sig(sig))
                except ValueError:
                    # No process found
                    pass
411
412
413
414
        existing = [sig['name'].lower() for sig in CONTEXT.signals]
        for each in all_sigs:
            if each['name'].lower() not in existing:
                CONTEXT.signals.append(each)
415
416
417
418
419

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

Maxime Perrotin's avatar
Maxime Perrotin committed
420

Maxime Perrotin's avatar
Maxime Perrotin committed
421
422
423
424
425
426
class Channel(Signalroute):
    ''' Connection between two processes. Signalroute handles connections
    between a function and the environment (edge of the screen). Here the
    start, middle and end point are redefined. They are stored with
    scene coordinates '''

427
428
429
430
431
432
    @Slot(float, float)
    def child_moved(self, delta_x, delta_y):
        ''' When the connection child moves - redefined function '''
        self._end_point.setX(self._end_point.x() - delta_x)
        self._end_point.setY(self._end_point.y() - delta_y)

Maxime Perrotin's avatar
Maxime Perrotin committed
433
434
435
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
436
        return self._start_point
Maxime Perrotin's avatar
Maxime Perrotin committed
437
438

    @start_point.setter
Maxime Perrotin's avatar
Maxime Perrotin committed
439
    def start_point(self, scene_coord : QPointF):
Maxime Perrotin's avatar
Maxime Perrotin committed
440
        ''' value is in scene coordinates '''
441
        self._start_point = self.parent.mapFromScene(scene_coord)
Maxime Perrotin's avatar
Maxime Perrotin committed
442
443
444
445
446
447
448

    @property
    def end_point(self):
        ''' Compute connection end point - redefined function '''
        return self.parent.mapFromScene(self._end_point)

    @end_point.setter
Maxime Perrotin's avatar
Maxime Perrotin committed
449
    def end_point(self, scene_coord : QPointF):
Maxime Perrotin's avatar
Maxime Perrotin committed
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
        ''' value is in scene coordinates '''
        self._end_point = scene_coord

    @property
    def middle_points(self):
        ''' Compute connection intermediate points - redefined function '''
        for each in self._middle_points:
            yield self.parent.mapFromScene(each)

    @middle_points.setter
    def middle_points(self, points_scene_coord):
        self._middle_points = points_scene_coord

    def add_point(self, scene_coord):
        self._middle_points.append(scene_coord)

466
467
468
469
470
471
    def paint(self, painter, _, ___):
        ''' Apply anti-aliasing '''
        painter.setRenderHint(QPainter.Antialiasing, True)
        super(Channel, self).paint(painter, _, ___)


Maxime Perrotin's avatar
Maxime Perrotin committed
472

Maxime Perrotin's avatar
Maxime Perrotin committed
473
class Controlpoint(QGraphicsPathItem):
Maxime Perrotin's avatar
Maxime Perrotin committed
474
    ''' Class handling one edge control point (to change bezier curves) '''
Maxime Perrotin's avatar
Maxime Perrotin committed
475
    user_can_connect = False  # not used but will avoid exceptions
Maxime Perrotin's avatar
Maxime Perrotin committed
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
    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
554
555
        # Set connection points as not visible, by default
        self.bezier_visible = False
Maxime Perrotin's avatar
Maxime Perrotin committed
556
557
558
559
560

        # 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)
Maxime Perrotin's avatar
Maxime Perrotin committed
561
        for i in range(1, len(self.edge['spline']), 3):
Maxime Perrotin's avatar
Maxime Perrotin committed
562
563
564
565
566
567
568
569
570
571
572
573
574
            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()

575
576
577
578
579
580
581
582
583
584
585
586
587
    @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
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
    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)

609
    # pylint: disable=R0914
Maxime Perrotin's avatar
Maxime Perrotin committed
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
    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
628
        arrowhead = self.angle_arrow(path)
629
630
631
        path.lineTo(arrowhead[0])
        path.moveTo(end_point)
        path.lineTo(arrowhead[1])
Maxime Perrotin's avatar
Maxime Perrotin committed
632
633
634
635
        path.moveTo(end_point)
        try:
            # Add the transition label, if any (none for the START edge)
            font = QFont('arial', pointSize=8)
636
            metrics = QFontMetrics(font)
637
            label = self.edge.get('label', '') or ''
638
639
640
641
            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
642
643
644
645
            pos = self.mapFromScene(*self.edge['lp'])
            if not self.text_label:
                self.text_label = QGraphicsTextItem(
                                 self.edge.get('label', ''), parent=self)
646
647
648
649
650
651
652
653
654
655
656
657
658
659
                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
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
        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])