Connectors.py 21.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
26

from PySide.QtGui import QGraphicsPathItem, QPainterPath, QGraphicsItem, QPen,\
                         QPainter, QFont, QGraphicsTextItem, QColor, \
                         QFontMetrics

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

Maxime Perrotin's avatar
Maxime Perrotin committed
72
73
74
75
76
77
    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
78
79
80
        return (QPointF(endp.x() - 5, endp.y() - 5),
                QPointF(endp.x() + 5, endp.y() - 5))

Maxime Perrotin's avatar
Maxime Perrotin committed
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
    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
97
98
                math.sin(angle - math.pi / 3) * arrow_size,
                math.cos(angle - math.pi / 3) * arrow_size)
Maxime Perrotin's avatar
Maxime Perrotin committed
99
        arrow_p2 = end_point + QPointF(
Maxime Perrotin's avatar
Maxime Perrotin committed
100
101
                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
102
103
104
105
106
107
108
109
        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)
110
        shape.lineTo(arrowhead[0])
Maxime Perrotin's avatar
Maxime Perrotin committed
111
        shape.moveTo(self.end_point if origin == 'head' else self.start_point)
112
113
        shape.lineTo(arrowhead[1])

Maxime Perrotin's avatar
Maxime Perrotin committed
114
115
116
117
118
119
120
    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 '''
121
122
123
124
125
126
127
        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
128
129
130
131
132
133
            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)
134
        self.setPath(shape)
Maxime Perrotin's avatar
Maxime Perrotin committed
135
136
137
138


class RakeConnection(Connection):
    ''' Fork-like connection, e.g. between a state and an input symbol '''
139
140
141
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
142
        parent_rect = self.parentItem().boundingRect()
143
144
145
146
147
148
149
150
151
152
153
154
155
156
        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
157
158
159
160
161


class JoinConnection(Connection):
    ''' Inverted fork-like connection, to join to a common point '''
    def reshape(self):
162
163
        ''' 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
164
        if self.parentItem().terminal_symbol:
165
            self.setPath(QPainterPath())
Maxime Perrotin's avatar
Maxime Perrotin committed
166
        else:
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
            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
187
188
189
190


class VerticalConnection(Connection):
    ''' Vertical line with or without arrow '''
191
192
193
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
194
        parent_rect = self.parentItem().boundingRect()
195
196
197
198
199
200
201
202
203
        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
204
205
206
207
208


class CommentConnection(Connection):
    ''' Class handling connection to comment symbols, fixed at the right
        of a symbol '''
209
210
211
    @property
    def start_point(self):
        ''' Compute connection origin - redefined function '''
Maxime Perrotin's avatar
Maxime Perrotin committed
212
        parent_rect = self.parentItem().boundingRect()
213
214
215
216
217
        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
218
        if self.child.on_the_right:
219
220
            return QPointF(self.child.x(),
                   self.child.y() + self.child.boundingRect().height() / 2)
Maxime Perrotin's avatar
Maxime Perrotin committed
221
        else:
222
223
224
225
226
227
            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
228
229
230
231
232
233
234
235
        # 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
236
237
        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
238
239


240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
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):
256
        ''' Resizing of the parent when size expands - ignore '''
257
258
        pass

259
260
261
262
263
    # pylint: disable=C0103
    def focusOutEvent(self, event):
        ''' Redefined function - update in_sig and out_sig in parent '''
        super(SignalList, self).focusOutEvent(event)

264
265

class Signalroute(Connection):
Maxime Perrotin's avatar
Maxime Perrotin committed
266
    ''' Subclass of Connection used to draw channels between processes '''
267
    in_sig = out_sig = None
268
269
    completion_list = set()

Maxime Perrotin's avatar
Maxime Perrotin committed
270
    def __init__(self, process):
Maxime Perrotin's avatar
Maxime Perrotin committed
271
        ''' Set generic parameters from Connection class '''
272
273
274
275
276
277
278
        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:
279
280
281
282
283
284
            # keep at class level as long as only one process is supported
            # when copy-pasting a process the challel in/out signal lists
            # 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
285
            Signalroute.in_sig = '{}'.format(',\n'.join(sig['name']
286
                                   for sig in process.input_signals))
287
            Signalroute.out_sig = '{}'.format(',\n'.join(sig['name']
288
                                    for sig in process.output_signals))
289
290
        self.label_in.setPlainText('[{}]'.format(self.in_sig))
        self.label_out.setPlainText('[{}]'.format(self.out_sig))
291
292
        self.label_in.document().contentsChanged.connect(self.change_siglist)
        self.label_out.document().contentsChanged.connect(self.change_siglist)
293
294
        for each in (self.label_in, self.label_out):
            each.show()
Maxime Perrotin's avatar
Maxime Perrotin committed
295
        self.process = process
296
        self.reshape()
Maxime Perrotin's avatar
Maxime Perrotin committed
297

298
299
300
301
302
303
304
305
306
307
308
309
310
    @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
311
312
313
314
315
    @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
316

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

    def reshape(self):
        ''' Redefine shape function to add the text areas '''
333
        super(Signalroute, self).reshape()
334
335
336
337
338
339
340

        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
341

342
343
344
345
346
347
348
349
350
351
352
353
    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 '''
354
        # TODO - call parseSingleElement, check sdlSymbols for examples
355
        pass
356
357
358
359
360

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

Maxime Perrotin's avatar
Maxime Perrotin committed
361
362
363
364
365
366
367
368
369
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

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
442
443
        # Set connection points as not visible, by default
        self.bezier_visible = False
Maxime Perrotin's avatar
Maxime Perrotin committed
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462

        # 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()

463
464
465
466
467
468
469
470
471
472
473
474
475
    @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
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
    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)

Maxime Perrotin's avatar
Maxime Perrotin committed
497
  # pylint: disable=R0914
Maxime Perrotin's avatar
Maxime Perrotin committed
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
    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
516
        arrowhead = self.angle_arrow(path)
517
518
519
        path.lineTo(arrowhead[0])
        path.moveTo(end_point)
        path.lineTo(arrowhead[1])
Maxime Perrotin's avatar
Maxime Perrotin committed
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
554
555
556
557
558
        path.moveTo(end_point)
        try:
            # Add the transition label, if any (none for the START edge)
            font = QFont('arial', pointSize=8)
            width = QFontMetrics(font).width(
                    self.edge.get('label', 0))
            pos = self.mapFromScene(*self.edge['lp'])
            #path.addText(pos.x() - width/2, pos.y(),
            #        font, self.edge['label'])
            if not self.text_label:
                self.text_label = QGraphicsTextItem(
                                 self.edge.get('label', ''), parent=self)
            self.text_label.setX(pos.x() - width / 2)
            self.text_label.setY(pos.y())
            self.text_label.setFont(font)
            self.text_label.show()
        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])