Statechart.py 30.5 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 functools import partial
26
from itertools import chain
27
import re
Maxime Perrotin's avatar
Maxime Perrotin committed
28
29
from PySide import QtGui, QtCore

30
31
32
33
34
35
# import resource file to get the configuration widget
from PySide.QtUiTools import QUiLoader
import icons

g_statechart_lock = False

Maxime Perrotin's avatar
Maxime Perrotin committed
36
37
38
39
40
41
42
43
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
44
from Connectors import Edge
Maxime Perrotin's avatar
Maxime Perrotin committed
45

Maxime Perrotin's avatar
Maxime Perrotin committed
46
RENDER_DPI = {'X': None, 'Y': None}
Maxime Perrotin's avatar
Maxime Perrotin committed
47
48
49
50
51
52
53
54
55
56
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
57
    _insertable_followers = ['Record', 'Diamond', 'Stop']
Maxime Perrotin's avatar
Maxime Perrotin committed
58
    _terminal_followers = []
59
    textbox_alignment = (QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter)
Maxime Perrotin's avatar
Maxime Perrotin committed
60
61
62
63
64
65
66

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

    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

100
101
102
103
    def mouse_move(self, event):
        ''' Disallow moving the symbols - this scene is auto-generated '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
104
105
106
107
108

# pylint: disable=R0904
class Point(genericSymbols.HorizontalSymbol, object):
    ''' Graphviz point node - used for START transition'''
    _unique_followers = []
Maxime Perrotin's avatar
Maxime Perrotin committed
109
    _insertable_followers = ['Record', 'Diamond', 'Stop']
Maxime Perrotin's avatar
Maxime Perrotin committed
110
111
112
    _terminal_followers = []
    textbox_alignment = (QtCore.Qt.AlignTop
                         | QtCore.Qt.AlignHCenter)
Maxime Perrotin's avatar
Maxime Perrotin committed
113
    has_text_area = False
Maxime Perrotin's avatar
Maxime Perrotin committed
114
115
116
117

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

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

146
147
148
149
    def mouse_move(self, event):
        ''' Disallow moving the symbols - this scene is auto-generated '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
150
151
152
153
154

# 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
155
    _insertable_followers = ['Record', 'Stop']
Maxime Perrotin's avatar
Maxime Perrotin committed
156
157
158
    _terminal_followers = []
    textbox_alignment = (QtCore.Qt.AlignTop
                         | QtCore.Qt.AlignHCenter)
Maxime Perrotin's avatar
Maxime Perrotin committed
159
    has_text_area = False
Maxime Perrotin's avatar
Maxime Perrotin committed
160
161
162
163

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

193
194
195
196
    def mouse_move(self, event):
        ''' Disallow moving the symbols - this scene is auto-generated '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
197

Maxime Perrotin's avatar
Maxime Perrotin committed
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# 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
215
216
        # Text in statecharts is read-only:
        self.text.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
Maxime Perrotin's avatar
Maxime Perrotin committed
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239

    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

240
241
242
243
    def mouse_move(self, event):
        ''' Disallow moving the symbols - this scene is auto-generated '''
        pass

Maxime Perrotin's avatar
Maxime Perrotin committed
244

Maxime Perrotin's avatar
Maxime Perrotin committed
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
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
274
            # Note: lp is the position of the CENTER of the label
Maxime Perrotin's avatar
Maxime Perrotin committed
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
308
309
310
311
312
313
314
            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 = []
315
316
317
318
319
320
321
322
323
324
325
326
327
    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
328
                         name='cluster_' + each.graph_attr['label'],
329
330
                         kind='cluster')
        attrs.append(attr)
Maxime Perrotin's avatar
Maxime Perrotin committed
331
    for node in my_graph.nodes_iter():
332
        if not dict(node.attr):
333
334
335
336
            continue
        node.attr.update(name=node.name)
        attrs.append(node.attr)
    for node in attrs:
Maxime Perrotin's avatar
Maxime Perrotin committed
337
338
        new_node = {}
        # Get the node shape attribute - default is record
339
        new_node['shape'] = node.get('shape') or 'record'
Maxime Perrotin's avatar
Maxime Perrotin committed
340
        # node main name
341
        new_node['name'] = node['name']
Maxime Perrotin's avatar
Maxime Perrotin committed
342
343
344
        try:
            # node complete label (can contain several compartments)
            properties = (
345
346
                    node['label'][1:-2].split('|')[1].replace('\\', '\n'))
            new_node['properties'] = properties
Maxime Perrotin's avatar
Maxime Perrotin committed
347
348
349
        except IndexError:
            pass
        # transform width and height from inches to pixels
350
351
352
353
354
355
        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
356
        # get the position of the CENTER of the node
357
        center_pos = [float(val) for val in node['pos'].split(',')]
Maxime Perrotin's avatar
Maxime Perrotin committed
358
        # apply dpi-conversion from 72 to actual screen DPI
Maxime Perrotin's avatar
Maxime Perrotin committed
359
360
361
        center_pos[0] *= (RENDER_DPI['X'] / dpi)

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

Maxime Perrotin's avatar
Maxime Perrotin committed
386
    lookup = {Point: 'point', Record: 'record',
387
              Diamond: 'diamond', Stop: 'square'}
Maxime Perrotin's avatar
Maxime Perrotin committed
388
389
390
391
392
393
394
395
396
397
    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'])

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

Maxime Perrotin's avatar
Maxime Perrotin committed
400
        if node['shape'] in (Point, Diamond, Stop):
Maxime Perrotin's avatar
Maxime Perrotin committed
401
402
            graph.add_node(node['name'], pos=pos, shape=lookup[node['shape']],
                fixedsize='true', width=node['width'] / RENDER_DPI['X'],
403
                height=node['height'] / RENDER_DPI['Y'], pin=True)
Maxime Perrotin's avatar
Maxime Perrotin committed
404
        else:
405
406
            graph.add_node(node['name'], pos=pos, pin=True,
                           shape=lookup[node['shape']])
Maxime Perrotin's avatar
Maxime Perrotin committed
407
408
409
410
411
412
413

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


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

440
441
442
443
444
445
    # 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)
446
        w = temp_scene.width()
447
448
449
        for node in graphtree['graph'].iternodes():
            if node == aname:
                size = temp_scene.itemsBoundingRect()
Maxime Perrotin's avatar
Maxime Perrotin committed
450
                node.attr['width'] = ((temp_scene.width() + 35)
451
452
453
                                      / RENDER_DPI['X'])
                node.attr['height'] = ((temp_scene.height() + 35)
                                      / RENDER_DPI['Y'])
454
455
                graphtree['children'][aname]['scene'] = temp_scene
                break
456
457
458
459

    # 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.
460
461
462
463
464
465
466
467
    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
468
469
470
471
    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
472
473
            node.attr['width'] = node.attr.get('width') or (min_width / 3.0)
            node.attr['height'] = node.attr.get('height') or (min_height / 3.0)
474
475


Maxime Perrotin's avatar
Maxime Perrotin committed
476
    # Statechart symbols lookup table
Maxime Perrotin's avatar
Maxime Perrotin committed
477
    lookup = {'point': Point, 'record': Record,
478
              'diamond': Diamond, 'square': Stop}
Maxime Perrotin's avatar
Maxime Perrotin committed
479
480
    try:
        # Bonus: the tool can render any dot graph...
481
        graph = graphtree.get('graph', None) or dotgraph.AGraph('taste.dot')
482
483
        config = " ".join("{name}={val}".format(name=name, val=val)
                for name, val in graphtree['config'].viewitems())
Maxime Perrotin's avatar
Maxime Perrotin committed
484
485
486
487
488
489
    except IOError:
        LOG.info('No statechart to display....')
        raise
    graph.graph_attr.update(dpi='72.0')
    EDGES[:] = graph.edges()

490
491
492
493
    for each in scene.visible_symb:
        each.setVisible(False)
    #scene.clear()
    #G_SYMBOLS.clear()
Maxime Perrotin's avatar
Maxime Perrotin committed
494
495
496

    # Compute all the coordinates (self-modifying function)
    # Force the fontsize of the nodes to be 12, as in OpenGEODE
497
    # use -n1 below to keep user-specified node coordinates
498
499
500
501
502
503
    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'

504
505
    graph.layout(prog='neato', args='{cfg} {kp} {dump}'
            .format(cfg=config, kp='-n1' if keep_pos else '',
506
                    dump=('-Tpng -o' + dump_gfx) if dump_gfx else ''))
Maxime Perrotin's avatar
Maxime Perrotin committed
507
508
509
510
511
512
513
    # 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')
514
515
    #with open('statechart.dot', 'w') as output:
    #    output.write(graph.to_string())
Maxime Perrotin's avatar
Maxime Perrotin committed
516
517
518
519
520
521
522

    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
523
524
525
            if unicode(node_symbol) in graphtree['children'] \
                    and shape == 'record':
                # Use a different color for non-terminal states
526
                node_symbol.setBrush(QtGui.QBrush(QtGui.QColor(249, 249, 249)))
527
528
529
            G_SYMBOLS.add(node_symbol)
            node_symbols.append(node_symbol)
            scene.addItem(node_symbol)
Maxime Perrotin's avatar
Maxime Perrotin committed
530
531
532
533
534
535
536
        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
537
538
539
    # Make sure the scene has no negative coordinates
    scene.translate_to_origin()

540
541
542
543
544
    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:
545
                deltapos = symb.scenePos() + QtCore.QPointF(30.0, 30.0)
546
                for each in agraph['scene'].floating_symb:
547
548
549
550
551
                    # 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)
552
                    scene.addItem(each)
553
554
                    each.position += deltapos
                    each.setZValue(each.zValue() + symb.zValue() + 1)
555

Maxime Perrotin's avatar
Maxime Perrotin committed
556

557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
def lock():
    ''' Prevent multiple callers to render at the same time '''
    global g_statechart_lock
    g_statechart_lock = True

def unlock():
    ''' Prevent multiple callers to render at the same time '''
    global g_statechart_lock
    g_statechart_lock = False

def locked():
    ''' Return the lock status '''
    return g_statechart_lock


572
def create_dot_graph(root_ast, basic=False, scene=None, view=None):
573
574
575
576
    ''' 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
    '''
577
    graph = dotgraph.AGraph(strict=False, directed=True)
578
    ret = {'graph': graph, 'children': {}, 'config': {}}
579
    diamond = 0
580
581
582
583

    input_signals = {sig['name'].lower() for sig in root_ast.input_signals}
    # XXX misses the timers

584
585
586
    # valid_inputs: list of messages to be displayed in the statecharts
    # user can remove them from the file to make cleaner diagrams
    # config_params can be set to tune the call to graphviz
587
    valid_inputs = set()
588
589
590
591
592
593
594
595
596
597
    config_params = {}
    inputs_to_save = set()
    identifier = getattr(root_ast, "statename", root_ast.processName)
    try:
        with open (identifier + ".cfg", "r") as cfg_file:
            all_lines = (line.strip() for line in cfg_file.readlines())
        for each in all_lines:
            split = each.split()
            if len(split) == 3 and split[0] == "cfg":
                config_params[split[1]] = split[2]
598
599
            elif each:
                valid_inputs.add(each.lower())
600
    except IOError:
601
        valid_inputs = input_signals
602
603
604
605
606
607
608
609
610
611
612
613
614
        config_params = {"-Nfontsize" : "12",
                         "-Efontsize" : "8",
                         "-Gsplines"  : "curved",
                         "-Gsep"      : "0.3",
                         "-Gdpi"      : "72",
                         "-Gstart"    : "random10",
                         "-Goverlap"  : "scale",
                         "-Nstyle"    : "rounded",
                         "-Nshape"    : "record",
                         "-Elen"      : "1"}
    else:
        LOG.info ("Statechart settings read from configuration file")

615
    if scene and view:
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
        # Load and display a table for the user to filter out messages that
        # are not relevant to display on the statechart - and make it lighter
        # Repeat for substates, too.
        lock()
        def right(leftList, rightList):
            for each in leftList.selectedItems():
                item = leftList.takeItem(leftList.row(each))
                rightList.addItem(item)
        def left(leftList, rightList):
            for each in rightList.selectedItems():
                item = rightList.takeItem(rightList.row(each))
                leftList.addItem(item)
        loader = QUiLoader()
        ui_file = QtCore.QFile(":/statechart_cfg.ui")
        ui_file.open(QtCore.QFile.ReadOnly)
        dialog = loader.load(ui_file)
632
        dialog.setParent (view, QtCore.Qt.Dialog)
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
        okButton = dialog.findChild(QtGui.QPushButton, "okButton")
        rightButton = dialog.findChild(QtGui.QToolButton, "toRight")
        leftButton = dialog.findChild(QtGui.QToolButton, "toLeft")
        rightList = dialog.findChild(QtGui.QListWidget, "rightList")
        leftList = dialog.findChild(QtGui.QListWidget, "leftList")
        okButton.pressed.connect(dialog.accept)
        rightButton.pressed.connect(partial(right, leftList, rightList))
        leftButton.pressed.connect(partial(left, leftList, rightList))
        ui_file.close()
        rightList.addItems(list(valid_inputs))
        leftList.addItems(list(input_signals - valid_inputs))
        go = dialog.exec_()
        valid_inputs.clear()
        for idx in xrange(rightList.count()):
            valid_inputs.add(rightList.item(idx).text())
        unlock()

650
651
652
653
654
655
656
    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
657

Maxime Perrotin's avatar
Maxime Perrotin committed
658
659
660
    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)
661
662
        ident = each.inputString or ' '
        graph.add_node(ident, label=ident, shape='square', width=10.0 / 72.0)
663
664
    for state, inputs in chain(root_ast.mapping.viewitems(),
                               root_ast.cs_mapping.viewitems()):
665
666
667
668
        # Add edges
        transitions = \
            inputs if not state.endswith('START') \
                   else [root_ast.transitions[inputs]]
669
670
671
        # Allow simplified graph, without diamonds and with at most one
        # transition from a given state to another
        target_states = defaultdict(set)
672
673
674
675
676
677
678
679
680
681
        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
682
683
684
685
686
687
688
                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 = ''
689
690

            def find_terminators(trans):
Maxime Perrotin's avatar
Maxime Perrotin committed
691
                ''' Recursively find all NEXTSTATES and RETURN nodes '''
692
                next_states = [term for term in trans.terminators
Maxime Perrotin's avatar
Maxime Perrotin committed
693
                               if term.kind in ('next_state', 'return')]
694
695
696
697
698
699
700
701
702
703
704
705
706
707
                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
708
709
710
711
712
713
                        try:
                            if corr_label.inputString != trans.inputString:
                                next_states.extend(find_terminators(corr_label))
                        except AttributeError:
                            # START transition -> no inputString
                            pass
714
715
716
717
718
                return set(next_states)

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

719
            if len(next_states) > 1 and not basic:
720
721
722
723
724
725
                # 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='')
726
                if label.lower() in valid_inputs or not label.strip():
727
                    graph.add_edge(source, str(diamond), label=label)
728
                    inputs_to_save.add(label.lower())
729
730
731
732
733
734
735
                source = str(diamond)
                label = ''
                diamond += 1
            for term in next_states:
                if term.inputString.strip() == '-':
                    target = state
                else:
736
                    target = term.inputString.lower() or ' '
737
                if basic:
738
739
740
741
742
743
744
                    target_states[target] |= set(label.split(','))
                else:
                    labs = set(lab.strip() for lab in label.split(',') if
                                    lab.strip().lower() in valid_inputs | {""})
                    actual = ',\n'.join(labs)
                    graph.add_edge(source, target, label=actual)
                    inputs_to_save |= set(lab.lower() for lab in labs)
745
        for target, labels in target_states.viewitems():
746
747
            sublab = [lab.strip() for lab in labels if
                      lab.strip().lower() in valid_inputs | {""}]
748
            # Basic mode
749
750
            if sublab:
                graph.add_edge(source, target, label=',\n'.join(sublab))
751
                inputs_to_save |= set(lab.lower() for lab in sublab)
752
753
754
#   with open('statechart.dot', 'w') as output:
#       output.write(graph.to_string())
    #return graph
755
756
757
758
759
    with open(identifier + ".cfg", "w") as cfg_file:
        for name, value in config_params.viewitems():
            cfg_file.write("cfg {} {}\n".format(name, value))
        for each in inputs_to_save:
            cfg_file.write(each + "\n")
760
    ret['config'] = config_params
761
    for each in root_ast.composite_states:
762
763
764
765
        # Recursively generate the graphs for nested states
        # Inherit from the list of signals from the higer level state
        each.input_signals = root_ast.input_signals
        ret['children'][each.statename] = create_dot_graph(each, basic, scene)
766
    return ret
767
768


Maxime Perrotin's avatar
Maxime Perrotin committed
769
770
if __name__ == '__main__':
    render_statechart(None)