Statechart.py 25.9 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
#!/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
"""

22
import os
Maxime Perrotin's avatar
Maxime Perrotin committed
23
import logging
24
from collections import defaultdict
25
from itertools import chain
26
import re
Maxime Perrotin's avatar
Maxime Perrotin committed
27
28
29
30
31
32
33
34
35
36
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
37
from Connectors import Edge
Maxime Perrotin's avatar
Maxime Perrotin committed
38

Maxime Perrotin's avatar
Maxime Perrotin committed
39
RENDER_DPI = {'X': None, 'Y': None}
Maxime Perrotin's avatar
Maxime Perrotin committed
40
41
42
43
44
45
46
47
48
49
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 = []
Maxime Perrotin's avatar
Maxime Perrotin committed
50
    _insertable_followers = ['Record', 'Diamond', 'Stop']
Maxime Perrotin's avatar
Maxime Perrotin committed
51
    _terminal_followers = []
52
    textbox_alignment = (QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter)
Maxime Perrotin's avatar
Maxime Perrotin committed
53
54
55
56
57
58
59

    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'])
60
        self.setBrush(QtGui.QBrush(QtGui.QColor(255, 255, 202)))
Maxime Perrotin's avatar
Maxime Perrotin committed
61
62
63
64
65
        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
66
67
        # Text in statecharts is read-only:
        self.text.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
68
        self.text.set_textbox_position()
Maxime Perrotin's avatar
Maxime Perrotin committed
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

    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

93
94
95
96
    def mouse_move(self, event):
        ''' Disallow moving the symbols - this scene is auto-generated '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
97
98
99
100
101

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

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

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

139
140
141
142
    def mouse_move(self, event):
        ''' Disallow moving the symbols - this scene is auto-generated '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
143
144
145
146
147

# pylint: disable=R0904
class Diamond(genericSymbols.HorizontalSymbol, object):
    ''' Graphviz node - it is floating and has no parent'''
    _unique_followers = []
Maxime Perrotin's avatar
Maxime Perrotin committed
148
    _insertable_followers = ['Record', 'Stop']
Maxime Perrotin's avatar
Maxime Perrotin committed
149
150
151
    _terminal_followers = []
    textbox_alignment = (QtCore.Qt.AlignTop
                         | QtCore.Qt.AlignHCenter)
Maxime Perrotin's avatar
Maxime Perrotin committed
152
    has_text_area = False
Maxime Perrotin's avatar
Maxime Perrotin committed
153
154
155
156

    def __init__(self, node, graph):
        ''' Initialization: compute the polygon shape '''
        self.name = node['name']
Maxime Perrotin's avatar
Maxime Perrotin committed
157
        super(Diamond, self).__init__(x=node['pos'][0], y=node['pos'][1])
Maxime Perrotin's avatar
Maxime Perrotin committed
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
        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

186
187
188
189
    def mouse_move(self, event):
        ''' Disallow moving the symbols - this scene is auto-generated '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
190

Maxime Perrotin's avatar
Maxime Perrotin committed
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# pylint: disable=R0904
class Stop(genericSymbols.HorizontalSymbol, object):
    ''' Graphviz state exit node - it is floating and has no parent'''
    _unique_followers = []
    _insertable_followers = []
    _terminal_followers = []
    textbox_alignment = (QtCore.Qt.AlignTop
                         | QtCore.Qt.AlignHCenter)
    has_text_area = True

    def __init__(self, node, graph):
        ''' Initialization: compute the polygon shape '''
        self.name = node['name']
        super(Stop, self).__init__(x=node['pos'][0], y=node['pos'][1],
                                   text=self.name)
        self.set_shape(node['width'], node['height'])
        self.graph = graph
208
209
        # Text in statecharts is read-only:
        self.text.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
Maxime Perrotin's avatar
Maxime Perrotin committed
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232

    def set_shape(self, width, height):
        ''' Define the polygon shape from width and height '''
        path = QtGui.QPainterPath()
        path.lineTo(width, height)
        path.moveTo(width, 0)
        path.lineTo(0, height)
        self.setPath(path)
        super(Stop, 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('Stop 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 '''
        pass

233
234
235
236
    def mouse_move(self, event):
        ''' Disallow moving the symbols - this scene is auto-generated '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
237

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
256
257
258
259
260
261
262
263
264
265
266
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
267
            # Note: lp is the position of the CENTER of the label
Maxime Perrotin's avatar
Maxime Perrotin committed
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
            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 = []
308
309
310
311
312
313
314
315
316
317
318
319
320
    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
321
                         name='cluster_' + each.graph_attr['label'],
322
323
                         kind='cluster')
        attrs.append(attr)
Maxime Perrotin's avatar
Maxime Perrotin committed
324
    for node in my_graph.nodes_iter():
325
        if not dict(node.attr):
326
327
328
329
            continue
        node.attr.update(name=node.name)
        attrs.append(node.attr)
    for node in attrs:
Maxime Perrotin's avatar
Maxime Perrotin committed
330
331
        new_node = {}
        # Get the node shape attribute - default is record
332
        new_node['shape'] = node.get('shape') or 'record'
Maxime Perrotin's avatar
Maxime Perrotin committed
333
        # node main name
334
        new_node['name'] = node['name']
Maxime Perrotin's avatar
Maxime Perrotin committed
335
336
337
        try:
            # node complete label (can contain several compartments)
            properties = (
338
339
                    node['label'][1:-2].split('|')[1].replace('\\', '\n'))
            new_node['properties'] = properties
Maxime Perrotin's avatar
Maxime Perrotin committed
340
341
342
        except IndexError:
            pass
        # transform width and height from inches to pixels
343
344
345
346
347
348
        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
349
        # get the position of the CENTER of the node
350
        center_pos = [float(val) for val in node['pos'].split(',')]
Maxime Perrotin's avatar
Maxime Perrotin committed
351
        # apply dpi-conversion from 72 to actual screen DPI
Maxime Perrotin's avatar
Maxime Perrotin committed
352
353
354
        center_pos[0] *= (RENDER_DPI['X'] / dpi)

        # translate y-coord from bottom-left to top-left corner
355
        center_pos[1] = (bb_height - center_pos[1]) * (RENDER_DPI['Y'] / dpi)
Maxime Perrotin's avatar
Maxime Perrotin committed
356
357
358
359
360
361
362
363
364
365
        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.
366
367
        This function is disabled because move of symbols is not possible
        anymore.
Maxime Perrotin's avatar
Maxime Perrotin committed
368
    '''
369
    return
Maxime Perrotin's avatar
Maxime Perrotin committed
370
371
372
373
    nodes = [{'name':node.name, 'pos':
        node.mapToScene(node.boundingRect().center()),
        'shape': type(node), 'width': node.boundingRect().width(),
        'height': node.boundingRect().height()}
374
        for node in scene.visible_symb if isinstance(node,
Maxime Perrotin's avatar
Maxime Perrotin committed
375
                                               (Point, Diamond, Record, Stop))]
Maxime Perrotin's avatar
Maxime Perrotin committed
376
377
378
    graph = dotgraph.AGraph(
            strict=False, directed=True, splines='spline', start='rand')

Maxime Perrotin's avatar
Maxime Perrotin committed
379
    lookup = {Point: 'point', Record: 'record',
380
              Diamond: 'diamond', Stop: 'square'}
Maxime Perrotin's avatar
Maxime Perrotin committed
381
382
383
384
385
386
387
388
389
390
    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'])

391
        pos = unicode('{x},{y}'.format(x=float(center_x), y=float(center_y)))
Maxime Perrotin's avatar
Maxime Perrotin committed
392

Maxime Perrotin's avatar
Maxime Perrotin committed
393
        if node['shape'] in (Point, Diamond, Stop):
Maxime Perrotin's avatar
Maxime Perrotin committed
394
395
            graph.add_node(node['name'], pos=pos, shape=lookup[node['shape']],
                fixedsize='true', width=node['width'] / RENDER_DPI['X'],
396
                height=node['height'] / RENDER_DPI['Y'], pin=True)
Maxime Perrotin's avatar
Maxime Perrotin committed
397
        else:
398
399
            graph.add_node(node['name'], pos=pos, pin=True,
                           shape=lookup[node['shape']])
Maxime Perrotin's avatar
Maxime Perrotin committed
400
401
402
403
404
405
406

    # 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:
407
        before = scene.itemsBoundingRect().center()
Maxime Perrotin's avatar
Maxime Perrotin committed
408
        #before_pos = graph.get_node(nodes[0]['name']).attr['pos']
409
410
        render_statechart(scene, {'graph': graph, 'children': {}},
                          keep_pos=True)
Maxime Perrotin's avatar
Maxime Perrotin committed
411
        #after_pos = graph.get_node(nodes[0]['name']).attr['pos']
412
413
        #print before_pos,after_pos
        delta = scene.itemsBoundingRect().center() - before
Maxime Perrotin's avatar
Maxime Perrotin committed
414
415
        # graphviz translates the graph to pos (0, 0) -> move it back
        # to the exact graphical position where the user clicked
416
        for item in scene.visible_symb:
417
            item.position = QtCore.QPointF(item.position - delta)
Maxime Perrotin's avatar
Maxime Perrotin committed
418
419


420
def render_statechart(scene, graphtree=None, keep_pos=False, dump_gfx=''):
421
422
    ''' Render a graphviz/dot statechart on the QGraphicsScene
        set a filename to "dump_gfx" parameter to create a PNG of the graph
423
424
        input is resulting from sdl_to_statechart, it contains a tree of graphs
        in case of composite states.
425
    '''
Maxime Perrotin's avatar
Maxime Perrotin committed
426
427
428
429
430
    # 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()
Maxime Perrotin's avatar
Maxime Perrotin committed
431
        break
Maxime Perrotin's avatar
Maxime Perrotin committed
432

433
434
435
436
437
438
    # Go recursive first: render children
    for aname, agraph in graphtree['children'].viewitems():
        # Render each child in a temporary scene to get the size of the scene
        # in order to resize the parent node accordingly
        temp_scene = type(scene)()
        render_statechart(temp_scene, agraph, keep_pos, dump_gfx)
439
        w = temp_scene.width()
440
441
442
        for node in graphtree['graph'].iternodes():
            if node == aname:
                size = temp_scene.itemsBoundingRect()
Maxime Perrotin's avatar
Maxime Perrotin committed
443
                node.attr['width'] = ((temp_scene.width() + 35)
444
445
446
                                      / RENDER_DPI['X'])
                node.attr['height'] = ((temp_scene.height() + 35)
                                      / RENDER_DPI['Y'])
447
448
                graphtree['children'][aname]['scene'] = temp_scene
                break
449
450
451
452

    # Harmonize the size of states to avoid having huge composite state(s)
    # next to single, small states. Rule: there can't be a state with a size
    # that is less than a third of the biggest state.
453
454
455
456
457
458
459
460
    try:
        min_width = float(max(node.attr.get('width', 0.0) or 0.0
                        for node in graphtree['graph'].iternodes()))
        min_height = float(max(node.attr.get('height', 0.0) or 0.0
                        for node in graphtree['graph'].iternodes()))
    except ValueError as err:
        LOG.debug(str(err))
        min_width, min_height = 0, 0
461
462
463
464
    if min_width and min_height:
        for node in graphtree['graph'].iternodes():
            if node.attr['shape'] != 'record':
                continue
Maxime Perrotin's avatar
Maxime Perrotin committed
465
466
            node.attr['width'] = node.attr.get('width') or (min_width / 3.0)
            node.attr['height'] = node.attr.get('height') or (min_height / 3.0)
467
468


Maxime Perrotin's avatar
Maxime Perrotin committed
469
    # Statechart symbols lookup table
Maxime Perrotin's avatar
Maxime Perrotin committed
470
    lookup = {'point': Point, 'record': Record,
471
              'diamond': Diamond, 'square': Stop}
Maxime Perrotin's avatar
Maxime Perrotin committed
472
473
    try:
        # Bonus: the tool can render any dot graph...
474
        graph = graphtree.get('graph', None) or dotgraph.AGraph('taste.dot')
Maxime Perrotin's avatar
Maxime Perrotin committed
475
476
477
478
479
480
    except IOError:
        LOG.info('No statechart to display....')
        raise
    graph.graph_attr.update(dpi='72.0')
    EDGES[:] = graph.edges()

481
482
483
484
    for each in scene.visible_symb:
        each.setVisible(False)
    #scene.clear()
    #G_SYMBOLS.clear()
Maxime Perrotin's avatar
Maxime Perrotin committed
485
486
487

    # Compute all the coordinates (self-modifying function)
    # Force the fontsize of the nodes to be 12, as in OpenGEODE
488
    # use -n1 below to keep user-specified node coordinates
489
490
491
492
493
494
    if dump_gfx:
        dump_name = 'sc_' + os.path.basename(dump_gfx)
        dump_gfx = os.path.dirname(dump_gfx) or '.' + os.sep + dump_name
        if dump_gfx.split('.')[-1].lower() != 'png':
            dump_gfx += '.png'

495
    graph.layout(prog='neato', args='-Nfontsize=12, -Efontsize=8 '
496
                 '-Gsplines=curved -Gsep=0.3 -Gdpi=72 '
Maxime Perrotin's avatar
Maxime Perrotin committed
497
                 '-Gstart=random10 -Goverlap=scale '
498
499
500
            '-Nstyle=rounded -Nshape=record -Elen=1 {kp} {dump}'
            .format(kp='-n1' if keep_pos else '',
                    dump=('-Tpng -o' + dump_gfx) if dump_gfx else ''))
Maxime Perrotin's avatar
Maxime Perrotin committed
501
502
503
504
505
506
507
    # 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'))

    #fontname = graph.graph_attr.get('fontname')
    #fontsize = graph.graph_attr.get('fontsize')
508
509
    #with open('statechart.dot', 'w') as output:
    #    output.write(graph.to_string())
Maxime Perrotin's avatar
Maxime Perrotin committed
510
511
512
513
514
515
516

    nodes = preprocess_nodes(graph, bounding_rect, dot_dpi)
    node_symbols = []
    for node in nodes:
        shape = node.get('shape')
        try:
            node_symbol = lookup[shape](node, graph)
Maxime Perrotin's avatar
Maxime Perrotin committed
517
518
519
            if unicode(node_symbol) in graphtree['children'] \
                    and shape == 'record':
                # Use a different color for non-terminal states
520
                node_symbol.setBrush(QtGui.QBrush(QtGui.QColor(249, 249, 249)))
521
522
523
            G_SYMBOLS.add(node_symbol)
            node_symbols.append(node_symbol)
            scene.addItem(node_symbol)
Maxime Perrotin's avatar
Maxime Perrotin committed
524
525
526
527
528
529
530
        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)

Maxime Perrotin's avatar
Maxime Perrotin committed
531
532
533
    # Make sure the scene has no negative coordinates
    scene.translate_to_origin()

534
535
536
537
538
    for aname, agraph in graphtree['children'].viewitems():
        # At the end, place the content of the scene of the composite states
        # in the symbol by moving them from their temporary scene
        for symb in scene.visible_symb:
            if unicode(symb) == aname:
539
                deltapos = symb.scenePos() + QtCore.QPointF(30.0, 30.0)
540
                for each in agraph['scene'].floating_symb:
541
542
543
544
545
                    # In principle we should change the parentItem to make sure
                    # that all children items are moved together with their
                    # parent. Unfortunately calls to setParentItem provoke
                    # a segfault. To be tried again when PySide is fixed...
                    #each.setParent(symb)
546
                    scene.addItem(each)
547
548
                    each.position += deltapos
                    each.setZValue(each.zValue() + symb.zValue() + 1)
549

Maxime Perrotin's avatar
Maxime Perrotin committed
550

551
552
553
554
555
def create_dot_graph(root_ast, basic=False):
    ''' Return a dot.AGraph item, from an ogAST.Process or child entry
        Set basic=True to generate a simple graph with at most one edge
        between two states and no diamond nodes
    '''
556
    graph = dotgraph.AGraph(strict=False, directed=True)
557
    ret = {'graph': graph, 'children': {}}
558
559
560
561
562
563
564
565
    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:
            graph.add_node(state, label=state, shape='record', style='rounded')
Maxime Perrotin's avatar
Maxime Perrotin committed
566

Maxime Perrotin's avatar
Maxime Perrotin committed
567
568
569
    for each in [term for term in root_ast.terminators
                 if term.kind == 'return']:
        # create a new node for each RETURN statement (in nested states)
570
571
        ident = each.inputString or ' '
        graph.add_node(ident, label=ident, shape='square', width=10.0 / 72.0)
572
573
    for state, inputs in chain(root_ast.mapping.viewitems(),
                               root_ast.cs_mapping.viewitems()):
574
575
576
577
        # Add edges
        transitions = \
            inputs if not state.endswith('START') \
                   else [root_ast.transitions[inputs]]
578
579
580
        # Allow simplified graph, without diamonds and with at most one
        # transition from a given state to another
        target_states = defaultdict(set)
581
582
583
584
585
586
587
588
589
590
        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
591
592
593
594
595
596
597
                for each in root_ast.content.named_start:
                    # each is of type ogAST.Start
                    if each.transition == trans:
                        label = each.inputString[:-6]
                        break
                else:
                    label = ''
598
599

            def find_terminators(trans):
Maxime Perrotin's avatar
Maxime Perrotin committed
600
                ''' Recursively find all NEXTSTATES and RETURN nodes '''
601
                next_states = [term for term in trans.terminators
Maxime Perrotin's avatar
Maxime Perrotin committed
602
                               if term.kind in ('next_state', 'return')]
603
604
605
606
607
608
609
610
611
612
613
614
615
616
                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
617
618
619
620
621
622
                        try:
                            if corr_label.inputString != trans.inputString:
                                next_states.extend(find_terminators(corr_label))
                        except AttributeError:
                            # START transition -> no inputString
                            pass
623
624
625
626
627
                return set(next_states)

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

628
            if len(next_states) > 1 and not basic:
629
630
631
632
633
634
635
636
637
638
639
640
641
642
                # 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:
643
                    target = term.inputString.lower() or ' '
644
645
646
647
648
649
650
                if basic:
                    target_states[target].add(label)
                else:
                    graph.add_edge(source, target, label=label)
        for target, labels in target_states.viewitems():
            # Basic mode
            graph.add_edge(source, target, label=',\n'.join(labels))
651
652
653
654
655
656
#   with open('statechart.dot', 'w') as output:
#       output.write(graph.to_string())
    #return graph
    for each in root_ast.composite_states:
        ret['children'][each.statename] = create_dot_graph(each, basic)
    return ret
657
658


Maxime Perrotin's avatar
Maxime Perrotin committed
659
660
if __name__ == '__main__':
    render_statechart(None)