Statechart.py 19.2 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
21
22
#!/usr/bin/env python

"""
    OpenGEODE - A tiny, free SDL Editor for TASTE

    SDL is the Specification and Description Language (Z100 standard from ITU)

    Copyright (c) 2012-2013 European Space Agency

    Designed and implemented by Maxime Perrotin

    This module is responsible for the rendering of statecharts using
    the graphviz library.

    Credits:
    Rendering algorithm to transform graphviz b-splines to to Qt bezier curves
    was developed by Steve Dodier-Lazaro (www.mupuf.org)

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

import logging
23
import re
Maxime Perrotin's avatar
Maxime Perrotin committed
24
25
26
27
28
29
30
31
32
33
from PySide import QtGui, QtCore

try:
    import pygraphviz as dotgraph
except ImportError:
    print('pygraphviz not found. Statechart module will not work\n'
          'If you are using Debian (or Ubuntu) you can install it from\n'
          'the official repos: sudo apt-get install python-pygraphviz')

import genericSymbols
Maxime Perrotin's avatar
Maxime Perrotin committed
34
from Connectors import Edge
Maxime Perrotin's avatar
Maxime Perrotin committed
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

RENDER_DPI = {'X': 93.0, 'Y': 95.0}
G_SYMBOLS = set()
EDGES = []

LOG = logging.getLogger(__name__)


# pylint: disable=R0904
class Record(genericSymbols.HorizontalSymbol, object):
    ''' Graphviz node - it is floating and has no parent'''
    _unique_followers = []
    _insertable_followers = ['Record', 'Diamond']
    _terminal_followers = []
    textbox_alignment = (QtCore.Qt.AlignTop
                         | QtCore.Qt.AlignHCenter)

    def __init__(self, node, graph):
        ''' Initialization: compute the polygon shape '''
        self.name = node['name']
        super(Record, self).__init__(x=node['pos'][0],
                y=node['pos'][1], text=self.name)
        self.set_shape(node['width'], node['height'])
        self.setBrush(QtGui.QBrush(QtGui.QColor(255, 255, 202)))
        self.graph = graph
        if 'properties' in node:
            property_box = QtGui.QGraphicsTextItem(self)
            property_box.setPos(5, 30)
            property_box.setPlainText(node['properties'])
Maxime Perrotin's avatar
Maxime Perrotin committed
64
65
        # Text in statecharts is read-only:
        self.text.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
Maxime Perrotin's avatar
Maxime Perrotin committed
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98

    def set_shape(self, width, height):
        ''' Define the polygon shape from width and height '''
        path = QtGui.QPainterPath()
        path.moveTo(0, 25)
        path.lineTo(width, 25)
        path.addRoundedRect(0, 0, width, height, 15, 15)
        self.setPath(path)
        super(Record, self).set_shape(width, height)

    def resize_item(self, _):
        ''' Redefine the resizing function - forbid resizing '''
        pass

    def __str__(self):
        ''' User-friendly information about the node '''
        return('Record ' + self.name + ' at pos ' + str(self.pos()) +
               ' bounding rect = ' + str(self.boundingRect()))

    def mouse_release(self, _):
        ''' After moving item, ask dot to recompute the edges '''
        # Discard mouse release default action (CAM, UndoCommand)
        pass


# pylint: disable=R0904
class Point(genericSymbols.HorizontalSymbol, object):
    ''' Graphviz point node - used for START transition'''
    _unique_followers = []
    _insertable_followers = ['Record', 'Diamond']
    _terminal_followers = []
    textbox_alignment = (QtCore.Qt.AlignTop
                         | QtCore.Qt.AlignHCenter)
Maxime Perrotin's avatar
Maxime Perrotin committed
99
    has_text_area = False
Maxime Perrotin's avatar
Maxime Perrotin committed
100
101
102
103

    def __init__(self, node, graph):
        ''' Initialization: compute the polygon shape '''
        self.name = node['name']
Maxime Perrotin's avatar
Maxime Perrotin committed
104
        super(Point, self).__init__(x=node['pos'][0], y=node['pos'][1])
Maxime Perrotin's avatar
Maxime Perrotin committed
105
106
107
        self.set_shape(node['width'], node['height'])
        self.setBrush(QtGui.QBrush(QtCore.Qt.black))
        self.graph = graph
108
        # Text is read only
Maxime Perrotin's avatar
Maxime Perrotin committed
109
        #self.text.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
Maxime Perrotin's avatar
Maxime Perrotin committed
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140

    def set_shape(self, width, height):
        ''' Define the polygon shape from width and height '''
        path = QtGui.QPainterPath()
        path.addEllipse(0, 0, width, height)
        self.setPath(path)
        super(Point, self).set_shape(width, height)

    def resize_item(self, _):
        ''' Redefine the resizing function - forbid resizing '''
        pass

    def __str__(self):
        ''' User-friendly information about the node '''
        return('Point ' + self.name + ' at pos ' + str(self.pos()) +
                ' bounding rect = ' + str(self.boundingRect()))

    def mouse_release(self, _):
        ''' After moving item, ask dot to recompute the edges '''
        #self.scene().refresh()
        update(self.scene())


# pylint: disable=R0904
class Diamond(genericSymbols.HorizontalSymbol, object):
    ''' Graphviz node - it is floating and has no parent'''
    _unique_followers = []
    _insertable_followers = ['Record']
    _terminal_followers = []
    textbox_alignment = (QtCore.Qt.AlignTop
                         | QtCore.Qt.AlignHCenter)
Maxime Perrotin's avatar
Maxime Perrotin committed
141
    has_text_area = False
Maxime Perrotin's avatar
Maxime Perrotin committed
142
143
144
145

    def __init__(self, node, graph):
        ''' Initialization: compute the polygon shape '''
        self.name = node['name']
Maxime Perrotin's avatar
Maxime Perrotin committed
146
        super(Diamond, self).__init__(x=node['pos'][0], y=node['pos'][1])
Maxime Perrotin's avatar
Maxime Perrotin committed
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
        self.set_shape(node['width'], node['height'])
        self.graph = graph

    def set_shape(self, width, height):
        ''' Define the polygon shape from width and height '''
        path = QtGui.QPainterPath()
        path.moveTo(width / 2, 0)
        path.lineTo(width, height / 2)
        path.lineTo(width / 2, height)
        path.lineTo(0, height / 2)
        path.lineTo(width / 2, 0)
        self.setPath(path)
        super(Diamond, self).set_shape(width, height)

    def resize_item(self, _):
        ''' Redefine the resizing function - forbid resizing '''
        pass

    def __str__(self):
        ''' User-friendly information about the node '''
        return('Node ' + self.name + ' at pos ' + str(self.pos()) +
                ' bounding rect = ' + str(self.boundingRect()))

    def mouse_release(self, _):
        ''' After moving item, ask dot to recompute the edges '''
        #update(self.scene())
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
175

Maxime Perrotin's avatar
Maxime Perrotin committed
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def edges(scene, node):
    ''' Return all edges of a given node '''
    for item in scene.items():
        if isinstance(item, Edge) and node in (
                item.edge['source'], item.edge['target']):
            yield item


# Front end part - use Graphviz to parse the input file and transform it
# pylint: disable=R0914
def preprocess_edges(my_graph, nodes, bounding_rect, dpi):
    ''' Parse edges and put them in a clean datastructure '''
    edges = []
    bb_height = bounding_rect[3]
    for edge in my_graph.edges_iter():
        new_edge = {}
        source, target = edge
        for node in nodes:
            if node.name == source:
                new_edge['source'] = node
            if node.name == target:
                new_edge['target'] = node
        new_edge['label'] = edge.attr.get('label')
        try:
            new_edge['lp'] = [float(val) for val in edge.attr['lp'].split(',')]
        except (ValueError, AttributeError):
            pass
        else:
            # translate the label position from dot-coordinates to Qt
            new_edge['lp'][0] *= RENDER_DPI['X'] / dpi
            new_edge['lp'][1] = (
                    bb_height - new_edge['lp'][1]) * (RENDER_DPI['Y'] / dpi)

        pos = edge.attr['pos'].split()

        if pos[0].startswith('e'):
            new_edge['end'] = [float(val)
                    for val in pos.pop(0).split(',')[1:]]
            # And translate position
            new_edge['end'][0] *= (RENDER_DPI['X'] / dpi)
            new_edge['end'][1] = (
                    bb_height - new_edge['end'][1]) * (RENDER_DPI['Y'] / dpi)

        elif pos[0].startswith('s'):
            new_edge['start'] = [float(val)
                    for val in pos.pop(0).split(',')[1:]]
            # And translate position
            new_edge['start'][0] *= (RENDER_DPI['X'] / dpi)
            new_edge['start'][1] = (
                    bb_height - new_edge['start'][1]) * (RENDER_DPI['Y'] / dpi)

        spline = []
        for entry in pos:
            point = [float(val) for val in entry.split(',')]
            point[0] *= (RENDER_DPI['X'] / dpi)
            point[1] = (bb_height - point[1]) * (RENDER_DPI['Y'] / dpi)

            spline.append(point)
        new_edge['spline'] = spline
        edges.append(new_edge)
    return edges


def preprocess_nodes(my_graph, bounding_rect, dpi):
    ''' Parse the nodes from the graph and create a clean datastructure '''
    # Graphviz calculates coordinates starting on the bottom-left corner,
    # while Qt uses the top-left corner, so we must translate all y-coord.
    bb_height = bounding_rect[3]
    nodes = []
245
246
247
248
249
250
251
252
253
254
255
256
257
    attrs = []
    for each in my_graph.subgraphs():
        # Transform cluster nodes into nodes (attributes differ)
        llx, lly, urx, ury = each.graph_attr['bb'].split(',')
        wid = float(urx) - float(llx)
        hei = float(ury) - float(lly)
        attr = {}
        attr.update(pos='{0},{1}'.format(llx, lly),
                         width=wid,
                         height=hei,
                         shape=each.graph_attr['shape'],
                         label=each.graph_attr['label'],
                         style=each.graph_attr['style'],
Maxime Perrotin's avatar
Maxime Perrotin committed
258
                         name='cluster_' + each.graph_attr['label'],
259
260
                         kind='cluster')
        attrs.append(attr)
Maxime Perrotin's avatar
Maxime Perrotin committed
261
    for node in my_graph.nodes_iter():
262
263
264
265
266
        if not node.attr:
            continue
        node.attr.update(name=node.name)
        attrs.append(node.attr)
    for node in attrs:
Maxime Perrotin's avatar
Maxime Perrotin committed
267
268
        new_node = {}
        # Get the node shape attribute - default is record
269
        new_node['shape'] = node.get('shape') or 'record'
Maxime Perrotin's avatar
Maxime Perrotin committed
270
        # node main name
271
        new_node['name'] = node['name']
Maxime Perrotin's avatar
Maxime Perrotin committed
272
273
274
        try:
            # node complete label (can contain several compartments)
            properties = (
275
276
                    node['label'][1:-2].split('|')[1].replace('\\', '\n'))
            new_node['properties'] = properties
Maxime Perrotin's avatar
Maxime Perrotin committed
277
278
279
        except IndexError:
            pass
        # transform width and height from inches to pixels
280
281
282
283
284
285
        if node.get('kind', '') == 'cluster':
            new_node['width'] = node['width']
            new_node['height'] = node['height']
        else:
            new_node['width'] = float(node['width']) * RENDER_DPI['X']
            new_node['height'] = float(node['height']) * RENDER_DPI['Y']
Maxime Perrotin's avatar
Maxime Perrotin committed
286
        # get the position of the CENTER of the node
287
        center_pos = [float(val) for val in node['pos'].split(',')]
Maxime Perrotin's avatar
Maxime Perrotin committed
288
289
290
291
        # apply dpi-conversion from 72 to 96
        center_pos[0] *= (RENDER_DPI['X'] / dpi)

        # translate y-coord from bottom-left to top-left corner
292
        center_pos[1] = (bb_height - center_pos[1]) * (RENDER_DPI['Y'] / dpi)
Maxime Perrotin's avatar
Maxime Perrotin committed
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
        new_node['pos'] = [center_pos[0] - (new_node['width'] / 2.0),
                           center_pos[1] - (new_node['height'] / 2.0)]
        nodes.append(new_node)
    return nodes


def update(scene):
    '''
        Parse the graph symbols and create a graphviz graph
        Used to update the edges in case user moves nodes.
    '''
    nodes = [{'name':node.name, 'pos':
        node.mapToScene(node.boundingRect().center()),
        'shape': type(node), 'width': node.boundingRect().width(),
        'height': node.boundingRect().height()}
        for node in scene.items() if isinstance(node,
                                               (Point, Diamond, Record))]
    graph = dotgraph.AGraph(
            strict=False, directed=True, splines='spline', start='rand')

    lookup = {Point: 'point', Record: 'record', Diamond: 'diamond'}
    for node in nodes:
        center_pos = node['pos']
        bb_height = scene.itemsBoundingRect().height()

        dpi = 72.0

        # Convert to graphviz coordinates / adapt DPI
        center_x = center_pos.x() * (dpi / RENDER_DPI['X'])
        center_y = (bb_height - center_pos.y()) * (dpi / RENDER_DPI['Y'])

324
325
        #pos = unicode('{x},{y}'.format(x=int(center_x), y=int(center_y)))
        pos = unicode('{x},{y}'.format(x=float(center_x), y=float(center_y)))
Maxime Perrotin's avatar
Maxime Perrotin committed
326
327
328
329

        if node['shape'] in (Point, Diamond):
            graph.add_node(node['name'], pos=pos, shape=lookup[node['shape']],
                fixedsize='true', width=node['width'] / RENDER_DPI['X'],
330
                height=node['height'] / RENDER_DPI['Y'], pin=True)
Maxime Perrotin's avatar
Maxime Perrotin committed
331
        else:
332
333
            graph.add_node(node['name'], pos=pos, pin=True,
                           shape=lookup[node['shape']])
Maxime Perrotin's avatar
Maxime Perrotin committed
334
335
336
337
338
339
340

    # Keep edges from the previous graph
    for edge in EDGES:
        graph.add_edge(edge, label=edge.attr.get('label') or '')

    #print graph.to_string()
    if nodes:
341
        before = scene.itemsBoundingRect().center()
Maxime Perrotin's avatar
Maxime Perrotin committed
342
        #before_pos = graph.get_node(nodes[0]['name']).attr['pos']
Maxime Perrotin's avatar
Maxime Perrotin committed
343
        render_statechart(scene, graph, keep_pos=True)
Maxime Perrotin's avatar
Maxime Perrotin committed
344
        #after_pos = graph.get_node(nodes[0]['name']).attr['pos']
345
346
        #print before_pos,after_pos
        delta = scene.itemsBoundingRect().center() - before
Maxime Perrotin's avatar
Maxime Perrotin committed
347
348
        # graphviz translates the graph to pos (0, 0) -> move it back
        # to the exact graphical position where the user clicked
349
350
        for item in scene.visible_symb:
            item.setPos(item.pos() - delta)
Maxime Perrotin's avatar
Maxime Perrotin committed
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365


def render_statechart(scene, graph=None, keep_pos=False):
    ''' Render a graphviz/dot statechart on the QGraphicsScene '''
    # Statechart symbols lookup table
    lookup = {'point': Point, 'record': Record, 'diamond': Diamond}
    try:
        # Bonus: the tool can render any dot graph...
        graph = graph or dotgraph.AGraph('taste.dot')
    except IOError:
        LOG.info('No statechart to display....')
        raise
    graph.graph_attr.update(dpi='72.0')
    EDGES[:] = graph.edges()

366
367
368
369
    for each in scene.visible_symb:
        each.setVisible(False)
    #scene.clear()
    #G_SYMBOLS.clear()
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

    # Compute all the coordinates (self-modifying function)
    # Force the fontsize of the nodes to be 12, as in OpenGEODE
    # use -n2 below to keep user-specified node coordinates
    graph.layout(prog='neato', args='-Nfontsize=12, -Gsplines=true -Gsep=1 '
            '-Gstart=random10 '
            '-Nstyle=rounded -Nshape=record -Elen=1.5 {kp}'
            .format(kp='-n1' if keep_pos else ''))
    # bb is not visible directly - extract it from the low level api:
    bounding_rect = [float(val) for val in
            dotgraph.graphviz.agget(graph.handle, 'bb').split(',')]
    dot_dpi = float(dotgraph.graphviz.agget(graph.handle, 'dpi'))
    # dot uses a 72 dpi value for converting its position coordinates
    # Get actual rendering DPI from Qt view
    for view in scene.views():
        RENDER_DPI['X'] = view.physicalDpiX()
        RENDER_DPI['Y'] = view.physicalDpiY()

    #fontname = graph.graph_attr.get('fontname')
    #fontsize = graph.graph_attr.get('fontsize')
    #print 'AFTER PROCESSING: ', graph.to_string()

    nodes = preprocess_nodes(graph, bounding_rect, dot_dpi)
    node_symbols = []
    for node in nodes:
395
        #print node
Maxime Perrotin's avatar
Maxime Perrotin committed
396
397
398
        shape = node.get('shape')
        try:
            node_symbol = lookup[shape](node, graph)
399
400
401
            G_SYMBOLS.add(node_symbol)
            node_symbols.append(node_symbol)
            scene.addItem(node_symbol)
Maxime Perrotin's avatar
Maxime Perrotin committed
402
403
404
405
406
407
408
409
        except KeyError:
            raise TypeError('Statechart - unsupported shape: ' + shape)
    edges = preprocess_edges(graph, node_symbols, bounding_rect, dot_dpi)

    for edge in edges:
        Edge(edge, graph)


410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
def create_dot_graph(root_ast):
    ''' Return a dot.AGraph item, from an ogAST.Process or child entry '''
    graph = dotgraph.AGraph(strict=False, directed=True)
    diamond = 0
    for state in root_ast.mapping.viewkeys():
        # create a new node for each state (including nested states)
        if state.endswith('START'):
            graph.add_node(state, label='', shape='point',
                           fixedsize='true', width=10.0 / 72.0)
        else:
            #print 'adding', state
            graph.add_node(state, label=state, shape='record', style='rounded')
    for each in root_ast.composite_states:
        # this will have to be recursive
        subnodes = (name for name in graph.iternodes()
                    if name.startswith(each.statename.lower() + '_'))
Maxime Perrotin's avatar
Maxime Perrotin committed
426
        graph.add_subgraph(subnodes, name='cluster_' + each.statename.lower(),
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
                           label=each.statename.lower(),
                           style='rounded', shape='record')
    for state, inputs in root_ast.mapping.viewitems():
        # Add edges
        transitions = \
            inputs if not state.endswith('START') \
                   else [root_ast.transitions[inputs]]
                   #[root_ast.content.start]
        for trans in transitions:
            source = state
            # transition label - there can be several inputs
            try:
                # Keep only message name, remove params and newlines
                # (newlines are not supported by graphviz)
                label, = re.match(r'([^(]+)', trans.inputString).groups()
                label = label.strip().replace('\n', ' ')
            except AttributeError:
                # START transition may have no inputString
                label = ''

            def find_terminators(trans):
                ''' Recursively find all NEXTSTATES '''
                next_states = [term for term in trans.terminators
                               if term.kind == 'next_state']
                joins = [term for term in trans.terminators
                         if term.kind == 'join']
                for join in joins:
                    # JOIN - Find corresponding label
                    try:
                        corr_label, = [lab for lab in
                                       root_ast.content.floating_labels +
                                       root_ast.labels if
                                       lab.inputString.lower() ==
                                       join.inputString.lower()]
                    except ValueError:
                        LOG.error('Missing label: ' + join.inputString)
                    else:
                        # Don't recurse forever in case of livelock
                        if corr_label.inputString != trans.inputString:
                            next_states.extend(find_terminators(corr_label))
                return set(next_states)

            # Determine the list of terminators in this transition
            next_states = find_terminators(trans)

            if len(next_states) > 1:
                # more than one terminator - add intermediate node
                graph.add_node(str(diamond),
                               shape='diamond',
                               fixedsize='true',
                               width=15.0 / 72.0,
                               height=15.0 / 72.0, label='')
                graph.add_edge(source, str(diamond), label=label)
                source = str(diamond)
                label = ''
                diamond += 1
            for term in next_states:
                if term.inputString.strip() == '-':
                    target = state
                else:
                    target = term.inputString.lower()
                LOG.debug('Edge from ' + source + ' to ' +
                           term.inputString + ' label: ' + label)
                for each in root_ast.composite_states:
                    # check with deeper nesting
                    if each.statename.lower() == target.lower():
                        target = 'cluster_' + target
                        break
                graph.add_edge(source, target, label=label)
    #print graph.to_string()
    return graph


Maxime Perrotin's avatar
Maxime Perrotin committed
500
501
if __name__ == '__main__':
    render_statechart(None)