Commits

Armin Rigo committed ae5d5dc

Merge the graphserver-dist branch.
For description, see my mail "Pygame viewer" on pypy-dev.

Comments (0)

Files changed (33)

dotviewer/VeraMoBd.ttf

Binary file added.

dotviewer/__init__.py

Empty file added.

dotviewer/conftest.py

+import py
+
+Option = py.test.config.Option
+option = py.test.config.addoptions("dotviewer options",
+        Option('--pygame', action="store_true", dest="pygame", default=False,
+               help="allow interactive tests using Pygame"),
+        )

dotviewer/cyrvetic.ttf

Binary file added.

dotviewer/dotviewer.py

+#! /usr/bin/env python
+"""
+Command-line interface for a dot file viewer.
+
+    dotviewer.py filename.dot
+    dotviewer.py filename.plain
+    dotviewer.py --server [interface:]port
+
+In the first form, show the graph contained in a .dot file.
+In the second form, the graph was already compiled to a .plain file.
+In the third form, listen for connexion on the given port and display
+the graphs sent by the remote side.
+"""
+
+import sys
+
+def main(args = sys.argv[1:]):
+    import getopt
+    options, args = getopt.getopt(args, 's:h', ['server=', 'help'])
+    server_addr = None
+    for option, value in options:
+        if option in ('-h', '--help'):
+            print >> sys.stderr, __doc__
+            sys.exit(2)
+        if option in ('-s', '--server'):
+            server_addr = value
+    if not args and server_addr is None:
+        print >> sys.stderr, __doc__
+        sys.exit(2)
+    for filename in args:
+        import graphclient
+        graphclient.display_dot_file(filename)
+    if server_addr is not None:
+        import graphserver
+        graphserver.listen_server(server_addr)
+
+if __name__ == '__main__':
+    main()

dotviewer/drawgraph.py

+"""
+A custom graphic renderer for the '.plain' files produced by dot.
+
+"""
+
+from __future__ import generators
+import re, os, math
+import pygame
+from pygame.locals import *
+
+
+this_dir = os.path.dirname(os.path.abspath(__file__))
+FONT = os.path.join(this_dir, 'cyrvetic.ttf')
+FIXEDFONT = os.path.join(this_dir, 'VeraMoBd.ttf')
+COLOR = {
+    'black': (0,0,0),
+    'white': (255,255,255),
+    'red': (255,0,0),
+    'green': (0,255,0),
+    'blue': (0,0,255),
+    'yellow': (255,255,0),
+    }
+re_nonword=re.compile(r'([^0-9a-zA-Z_.]+)')
+
+def combine(color1, color2, alpha):
+    r1, g1, b1 = color1
+    r2, g2, b2 = color2
+    beta = 1.0 - alpha
+    return (int(r1 * alpha + r2 * beta),
+            int(g1 * alpha + g2 * beta),
+            int(b1 * alpha + b2 * beta))
+
+
+def highlight_color(color):
+    if color == (0, 0, 0): # black becomes magenta
+        return (255, 0, 255)
+    elif color == (255, 255, 255): # white becomes yellow
+        return (255, 255, 0)
+    intensity = sum(color)
+    if intensity > 191 * 3:
+        return combine(color, (128, 192, 0), 0.2)
+    else:
+        return combine(color, (255, 255, 0), 0.2)
+
+def getcolor(name, default):
+    if name in COLOR:
+        return COLOR[name]
+    elif name.startswith('#') and len(name) == 7:
+        rval = COLOR[name] = (int(name[1:3],16), int(name[3:5],16), int(name[5:7],16))
+        return rval
+    else:
+        return default
+
+
+class GraphLayout:
+    fixedfont = False
+
+    def __init__(self, scale, width, height):
+        self.scale = scale
+        self.boundingbox = width, height
+        self.nodes = {}
+        self.edges = []
+        self.links = {}
+
+    def add_node(self, *args):
+        n = Node(*args)
+        self.nodes[n.name] = n
+
+    def add_edge(self, *args):
+        self.edges.append(Edge(self.nodes, *args))
+
+    def get_display(self):
+        from graphdisplay import GraphDisplay
+        return GraphDisplay(self)      
+
+    def display(self):
+        self.get_display().run()
+
+    def reload(self):
+        return self
+
+# async interaction helpers
+
+def display_async_quit():
+    pygame.event.post(pygame.event.Event(QUIT))        
+
+def display_async_cmd(**kwds):                
+    pygame.event.post(pygame.event.Event(USEREVENT, **kwds))
+
+EventQueue = []
+
+def wait_for_events():
+    if not EventQueue:
+        EventQueue.append(pygame.event.wait())
+        EventQueue.extend(pygame.event.get())
+
+def wait_for_async_cmd():
+    # wait until another thread pushes a USEREVENT in the queue
+    while True:
+        wait_for_events()
+        e = EventQueue.pop(0)
+        if e.type in (USEREVENT, QUIT):   # discard all other events
+            break
+    EventQueue.insert(0, e)   # re-insert the event for further processing
+
+
+class Node:
+    def __init__(self, name, x, y, w, h, label, style, shape, color, fillcolor):
+        self.name = name
+        self.x = float(x)
+        self.y = float(y)
+        self.w = float(w)
+        self.h = float(h)
+        self.label = label
+        self.style = style
+        self.shape = shape
+        self.color = color
+        self.fillcolor = fillcolor
+        self.highlight = False
+
+    def sethighlight(self, which):
+        self.highlight = bool(which)
+
+class Edge:
+    label = None
+    
+    def __init__(self, nodes, tail, head, cnt, *rest):
+        self.tail = nodes[tail]
+        self.head = nodes[head]
+        cnt = int(cnt)
+        self.points = [(float(rest[i]), float(rest[i+1]))
+                       for i in range(0, cnt*2, 2)]
+        rest = rest[cnt*2:]
+        if len(rest) > 2:
+            self.label, xl, yl = rest[:3]
+            self.xl = float(xl)
+            self.yl = float(yl)
+            rest = rest[3:]
+        self.style, self.color = rest
+        self.highlight = False
+        self.cachedbezierpoints = None
+        self.cachedarrowhead = None
+        self.cachedlimits = None
+
+    def sethighlight(self, which):
+        self.highlight = bool(which)
+
+    def limits(self):
+        result = self.cachedlimits
+        if result is None:
+            points = self.bezierpoints()
+            xs = [point[0] for point in points]
+            ys = [point[1] for point in points]
+            self.cachedlimits = result = (min(xs), max(ys), max(xs), min(ys))
+        return result
+
+    def bezierpoints(self):
+        result = self.cachedbezierpoints
+        if result is None:
+            result = []
+            pts = self.points
+            for i in range(0, len(pts)-3, 3):
+                result += beziercurve(pts[i], pts[i+1], pts[i+2], pts[i+3])
+            self.cachedbezierpoints = result
+        return result
+
+    def arrowhead(self):
+        result = self.cachedarrowhead
+        if result is None:
+            bottom_up = self.points[0][1] > self.points[-1][1]
+            if (self.tail.y > self.head.y) != bottom_up:   # reversed edge
+                head = 0
+                dir = 1
+            else:
+                head = -1
+                dir = -1
+            n = 1
+            while True:
+                try:
+                    x0, y0 = self.points[head]
+                    x1, y1 = self.points[head+n*dir]
+                except IndexError:
+                    result = []
+                    break
+                vx = x0-x1
+                vy = y0-y1
+                try:
+                    f = 0.12 / math.sqrt(vx*vx + vy*vy)
+                    vx *= f
+                    vy *= f
+                    result = [(x0 + 0.9*vx, y0 + 0.9*vy),
+                              (x0 + 0.4*vy, y0 - 0.4*vx),
+                              (x0 - 0.4*vy, y0 + 0.4*vx)]
+                    break
+                except (ZeroDivisionError, ValueError):
+                    n += 1
+            self.cachedarrowhead = result
+        return result
+
+def beziercurve((x0,y0), (x1,y1), (x2,y2), (x3,y3), resolution=8):
+    result = []
+    f = 1.0/(resolution-1)
+    append = result.append
+    for i in range(resolution):
+        t = f*i
+        t0 = (1-t)*(1-t)*(1-t)
+        t1 =   t  *(1-t)*(1-t) * 3.0
+        t2 =   t  *  t  *(1-t) * 3.0
+        t3 =   t  *  t  *  t
+        append((x0*t0 + x1*t1 + x2*t2 + x3*t3,
+                y0*t0 + y1*t1 + y2*t2 + y3*t3))
+    return result
+
+def segmentdistance((x0,y0), (x1,y1), (x,y)):
+    "Distance between the point (x,y) and the segment (x0,y0)-(x1,y1)."
+    vx = x1-x0
+    vy = y1-y0
+    try:
+        l = math.hypot(vx, vy)
+        vx /= l
+        vy /= l
+        dlong = vx*(x-x0) + vy*(y-y0)
+    except (ZeroDivisionError, ValueError):
+        dlong = -1
+    if dlong < 0.0:
+        return math.hypot(x-x0, y-y0)
+    elif dlong > l:
+        return math.hypot(x-x1, y-y1)
+    else:
+        return abs(vy*(x-x0) - vx*(y-y0))
+
+
+class GraphRenderer:
+    MARGIN = 0.6
+    SCALEMIN = 3
+    SCALEMAX = 100
+    FONTCACHE = {}
+    
+    def __init__(self, screen, graphlayout, scale=75):
+        self.graphlayout = graphlayout
+        self.setscale(scale)
+        self.setoffset(0, 0)
+        self.screen = screen
+        self.textzones = []
+        self.highlightwords = graphlayout.links
+        self.highlight_word = None
+        self.visiblenodes = []
+        self.visibleedges = []
+
+    def wordcolor(self, word):
+        info = self.highlightwords[word]
+        if isinstance(info, tuple) and len(info) >= 2:
+            color = info[1]
+        else:
+            color = None
+        if color is None:
+            color = (128,0,0)
+        if word == self.highlight_word:
+            return ((255,255,80), color)
+        else:
+            return (color, None)
+
+    def setscale(self, scale):
+        scale = max(min(scale, self.SCALEMAX), self.SCALEMIN)
+        self.scale = float(scale)
+        w, h = self.graphlayout.boundingbox
+        self.margin = int(self.MARGIN * scale)
+        self.width = int(w * scale) + (2 * self.margin)
+        self.height = int(h * scale) + (2 * self.margin)
+        self.bboxh = h
+        size = int(15 * (scale-10) / 75)
+        self.font = self.getfont(size)
+
+    def getfont(self, size):
+        if size in self.FONTCACHE:
+            return self.FONTCACHE[size]
+        elif size < 5:
+            self.FONTCACHE[size] = None
+            return None
+        else:
+            if self.graphlayout.fixedfont:
+                filename = FIXEDFONT
+            else:
+                filename = FONT
+            font = self.FONTCACHE[size] = pygame.font.Font(filename, size)
+            return font
+    
+    def setoffset(self, offsetx, offsety):
+        "Set the (x,y) origin of the rectangle where the graph will be rendered."
+        self.ofsx = offsetx - self.margin
+        self.ofsy = offsety - self.margin
+
+    def shiftoffset(self, dx, dy):
+        self.ofsx += dx
+        self.ofsy += dy
+
+    def getcenter(self):
+        w, h = self.screen.get_size()
+        return self.revmap(w//2, h//2)
+
+    def setcenter(self, x, y):
+        w, h = self.screen.get_size()
+        x, y = self.map(x, y)
+        self.shiftoffset(x-w//2, y-h//2)
+
+    def shiftscale(self, factor, fix=None):
+        if fix is None:
+            fixx, fixy = self.screen.get_size()
+            fixx //= 2
+            fixy //= 2
+        else:
+            fixx, fixy = fix
+        x, y = self.revmap(fixx, fixy)
+        self.setscale(self.scale * factor)
+        newx, newy = self.map(x, y)
+        self.shiftoffset(newx - fixx, newy - fixy)
+
+    def reoffset(self, swidth, sheight):
+        offsetx = noffsetx = self.ofsx
+        offsety = noffsety = self.ofsy
+        width = self.width
+        height = self.height
+
+        # if it fits, center it, otherwise clamp
+        if width <= swidth:
+            noffsetx = (width - swidth) // 2
+        else:
+            noffsetx = min(max(0, offsetx), width - swidth)
+
+        if height <= sheight:
+            noffsety = (height - sheight) // 2
+        else:
+            noffsety = min(max(0, offsety), height - sheight)
+
+        self.ofsx = noffsetx
+        self.ofsy = noffsety
+
+    def getboundingbox(self):
+        "Get the rectangle where the graph will be rendered."
+        return (-self.ofsx, -self.ofsy, self.width, self.height)
+
+    def visible(self, x1, y1, x2, y2):
+        """Is any part of the box visible (i.e. within the bounding box)?
+
+        We have to perform clipping ourselves because with big graphs the
+        coordinates may sometimes become longs and cause OverflowErrors
+        within pygame.
+        """
+        w, h = self.screen.get_size()
+        return x1 < w and x2 > 0 and y1 < h and y2 > 0
+
+    def computevisible(self):
+        del self.visiblenodes[:]
+        del self.visibleedges[:]
+        w, h = self.screen.get_size()
+        for node in self.graphlayout.nodes.values():
+            x, y = self.map(node.x, node.y)
+            nw2 = int(node.w * self.scale)//2
+            nh2 = int(node.h * self.scale)//2
+            if x-nw2 < w and x+nw2 > 0 and y-nh2 < h and y+nh2 > 0:
+                self.visiblenodes.append(node)
+        for edge in self.graphlayout.edges:
+            x1, y1, x2, y2 = edge.limits()
+            x1, y1 = self.map(x1, y1)
+            if x1 < w and y1 < h:
+                x2, y2 = self.map(x2, y2)
+                if x2 > 0 and y2 > 0:
+                    self.visibleedges.append(edge)
+
+    def map(self, x, y):
+        return (int(x*self.scale) - (self.ofsx - self.margin),
+                int((self.bboxh-y)*self.scale) - (self.ofsy - self.margin))
+
+    def revmap(self, px, py):
+        return ((px + (self.ofsx - self.margin)) / self.scale,
+                self.bboxh - (py + (self.ofsy - self.margin)) / self.scale)
+
+    def draw_node_commands(self, node):
+        xcenter, ycenter = self.map(node.x, node.y)
+        boxwidth = int(node.w * self.scale)
+        boxheight = int(node.h * self.scale)
+        fgcolor = getcolor(node.color, (0,0,0))
+        bgcolor = getcolor(node.fillcolor, (255,255,255))
+        if node.highlight:
+            fgcolor = highlight_color(fgcolor)
+            bgcolor = highlight_color(bgcolor)
+
+        text = node.label
+        lines = text.replace('\\l','\\l\n').replace('\r','\r\n').split('\n')
+        # ignore a final newline
+        if not lines[-1]:
+            del lines[-1]
+        wmax = 0
+        hmax = 0
+        commands = []
+        bkgndcommands = []
+
+        if self.font is None:
+            if lines:
+                raw_line = lines[0].replace('\\l','').replace('\r','')
+                if raw_line:
+                    for size in (12, 10, 8, 6, 4):
+                        font = self.getfont(size)
+                        img = TextSnippet(self, raw_line, (0, 0, 0), bgcolor, font=font)
+                        w, h = img.get_size()
+                        if (w >= boxwidth or h >= boxheight):
+                            continue
+                        else:
+                            if w>wmax: wmax = w
+                            def cmd(img=img, y=hmax, w=w):
+                                img.draw(xcenter-w//2, ytop+y)
+                            commands.append(cmd)
+                            hmax += h
+                            break
+        else:
+            for line in lines:
+                raw_line = line.replace('\\l','').replace('\r','') or ' '
+                img = TextSnippet(self, raw_line, (0, 0, 0), bgcolor)
+                w, h = img.get_size()
+                if w>wmax: wmax = w
+                if raw_line.strip():
+                    if line.endswith('\\l'):
+                        def cmd(img=img, y=hmax):
+                            img.draw(xleft, ytop+y)
+                    elif line.endswith('\r'):
+                        def cmd(img=img, y=hmax, w=w):
+                            img.draw(xright-w, ytop+y)
+                    else:
+                        def cmd(img=img, y=hmax, w=w):
+                            img.draw(xcenter-w//2, ytop+y)
+                    commands.append(cmd)
+                hmax += h
+                #hmax += 8
+
+        # we know the bounding box only now; setting these variables will
+        # have an effect on the values seen inside the cmd() functions above
+        xleft = xcenter - wmax//2
+        xright = xcenter + wmax//2
+        ytop = ycenter - hmax//2
+        x = xcenter-boxwidth//2
+        y = ycenter-boxheight//2
+
+        if node.shape == 'box':
+            rect = (x-1, y-1, boxwidth+2, boxheight+2)
+            def cmd():
+                self.screen.fill(bgcolor, rect)
+            bkgndcommands.append(cmd)
+            def cmd():
+                pygame.draw.rect(self.screen, fgcolor, rect, 1)
+            commands.append(cmd)
+        elif node.shape == 'ellipse':
+            rect = (x-1, y-1, boxwidth+2, boxheight+2)
+            def cmd():
+                pygame.draw.ellipse(self.screen, bgcolor, rect, 0)
+            bkgndcommands.append(cmd)
+            def cmd():
+                pygame.draw.ellipse(self.screen, fgcolor, rect, 1)
+            commands.append(cmd)
+        elif node.shape == 'octagon':
+            step = 1-math.sqrt(2)/2
+            points = [(int(x+boxwidth*fx), int(y+boxheight*fy))
+                      for fx, fy in [(step,0), (1-step,0),
+                                     (1,step), (1,1-step),
+                                     (1-step,1), (step,1),
+                                     (0,1-step), (0,step)]]
+            def cmd():
+                pygame.draw.polygon(self.screen, bgcolor, points, 0)
+            bkgndcommands.append(cmd)
+            def cmd():
+                pygame.draw.polygon(self.screen, fgcolor, points, 1)
+            commands.append(cmd)
+        return bkgndcommands, commands
+
+    def draw_commands(self):
+        nodebkgndcmd = []
+        nodecmd = []
+        for node in self.visiblenodes:
+            cmd1, cmd2 = self.draw_node_commands(node)
+            nodebkgndcmd += cmd1
+            nodecmd += cmd2
+
+        edgebodycmd = []
+        edgeheadcmd = []
+        for edge in self.visibleedges:
+
+            fgcolor = getcolor(edge.color, (0,0,0))
+            if edge.highlight:
+                fgcolor = highlight_color(fgcolor)
+            points = [self.map(*xy) for xy in edge.bezierpoints()]
+
+            def drawedgebody(points=points, fgcolor=fgcolor):
+                pygame.draw.lines(self.screen, fgcolor, False, points)
+            edgebodycmd.append(drawedgebody)
+
+            points = [self.map(*xy) for xy in edge.arrowhead()]
+            if points:
+                def drawedgehead(points=points, fgcolor=fgcolor):
+                    pygame.draw.polygon(self.screen, fgcolor, points, 0)
+                edgeheadcmd.append(drawedgehead)
+
+            if edge.label:
+                x, y = self.map(edge.xl, edge.yl)
+                img = TextSnippet(self, edge.label, (0, 0, 0))
+                w, h = img.get_size()
+                if self.visible(x-w//2, y-h//2, x+w//2, y+h//2):
+                    def drawedgelabel(img=img, x1=x-w//2, y1=y-h//2):
+                        img.draw(x1, y1)
+                    edgeheadcmd.append(drawedgelabel)
+
+        return edgebodycmd + nodebkgndcmd + edgeheadcmd + nodecmd
+
+    def render(self):
+        self.computevisible()
+
+        bbox = self.getboundingbox()
+        ox, oy, width, height = bbox
+        dpy_width, dpy_height = self.screen.get_size()
+        # some versions of the SDL misinterpret widely out-of-range values,
+        # so clamp them
+        if ox < 0:
+            width += ox
+            ox = 0
+        if oy < 0:
+            height += oy
+            oy = 0
+        if width > dpy_width:
+            width = dpy_width
+        if height > dpy_height:
+            height = dpy_height
+        self.screen.fill((224, 255, 224), (ox, oy, width, height))
+
+        # gray off-bkgnd areas
+        gray = (128, 128, 128)
+        if ox > 0:
+            self.screen.fill(gray, (0, 0, ox, dpy_height))
+        if oy > 0:
+            self.screen.fill(gray, (0, 0, dpy_width, oy))
+        w = dpy_width - (ox + width)
+        if w > 0:
+            self.screen.fill(gray, (dpy_width-w, 0, w, dpy_height))
+        h = dpy_height - (oy + height)
+        if h > 0:
+            self.screen.fill(gray, (0, dpy_height-h, dpy_width, h))
+
+        # draw the graph and record the position of texts
+        del self.textzones[:]
+        for cmd in self.draw_commands():
+            cmd()
+
+    def findall(self, searchstr):
+        """Return an iterator for all nodes and edges that contain a searchstr.
+        """
+        for item in self.graphlayout.nodes.itervalues():
+            if item.label and searchstr in item.label:
+                yield item
+        for item in self.graphlayout.edges:
+            if item.label and searchstr in item.label:
+                yield item
+
+    def at_position(self, (x, y)):
+        """Figure out the word under the cursor."""
+        for rx, ry, rw, rh, word in self.textzones:
+            if rx <= x < rx+rw and ry <= y < ry+rh:
+                return word
+        return None
+
+    def node_at_position(self, (x, y)):
+        """Return the Node under the cursor."""
+        x, y = self.revmap(x, y)
+        for node in self.visiblenodes:
+            if 2.0*abs(x-node.x) <= node.w and 2.0*abs(y-node.y) <= node.h:
+                return node
+        return None
+
+    def edge_at_position(self, (x, y), distmax=14):
+        """Return the Edge near the cursor."""
+        # XXX this function is very CPU-intensive and makes the display kinda sluggish
+        distmax /= self.scale
+        xy = self.revmap(x, y)
+        closest_edge = None
+        for edge in self.visibleedges:
+            pts = edge.bezierpoints()
+            for i in range(1, len(pts)):
+                d = segmentdistance(pts[i-1], pts[i], xy)
+                if d < distmax:
+                    distmax = d
+                    closest_edge = edge
+        return closest_edge
+
+
+class TextSnippet:
+    
+    def __init__(self, renderer, text, fgcolor, bgcolor=None, font=None):
+        self.renderer = renderer
+        self.imgs = []
+        self.parts = []
+        if font is None:
+            font = renderer.font
+        if font is None:
+            return
+        parts = self.parts
+        for word in re_nonword.split(text):
+            if not word:
+                continue
+            if word in renderer.highlightwords:
+                fg, bg = renderer.wordcolor(word)
+                bg = bg or bgcolor
+            else:
+                fg, bg = fgcolor, bgcolor
+            parts.append((word, fg, bg))
+        # consolidate sequences of words with the same color
+        for i in range(len(parts)-2, -1, -1):
+            if parts[i][1:] == parts[i+1][1:]:
+                word, fg, bg = parts[i]
+                parts[i] = word + parts[i+1][0], fg, bg
+                del parts[i+1]
+        # delete None backgrounds
+        for i in range(len(parts)):
+            if parts[i][2] is None:
+                parts[i] = parts[i][:2]
+        # render parts
+        i = 0
+        while i < len(parts):
+            part = parts[i]
+            word = part[0]
+            try:
+                try:
+                    img = font.render(word, False, *part[1:])
+                except pygame.error, e:
+                    # Try *with* anti-aliasing to work around a bug in SDL
+                    img = font.render(word, True, *part[1:])
+            except pygame.error:
+                del parts[i]   # Text has zero width
+            else:
+                self.imgs.append(img)
+                i += 1
+
+    def get_size(self):
+        if self.imgs:
+            sizes = [img.get_size() for img in self.imgs]
+            return sum([w for w,h in sizes]), max([h for w,h in sizes])
+        else:
+            return 0, 0
+
+    def draw(self, x, y):
+        for part, img in zip(self.parts, self.imgs):
+            word = part[0]
+            self.renderer.screen.blit(img, (x, y))
+            w, h = img.get_size()
+            self.renderer.textzones.append((x, y, w, h, word))
+            x += w
+
+
+try:
+    sum   # 2.3 only
+except NameError:
+    def sum(lst):
+        total = 0
+        for item in lst:
+            total += lst
+        return total

dotviewer/graphclient.py

+import os, sys, re
+import msgstruct
+
+this_dir = os.path.dirname(os.path.abspath(__file__))
+GRAPHSERVER = os.path.join(this_dir, 'graphserver.py')
+
+
+def display_dot_file(dotfile, wait=True):
+    """ Display the given dot file in a subprocess.
+    """
+    if not os.path.exists(str(dotfile)):
+        raise IOError("No such file: %s" % (dotfile,))
+    import graphpage
+    page = graphpage.DotFileGraphPage(str(dotfile))
+    display_page(page, wait=wait)
+
+def display_page(page, wait=True):
+    messages = [(msgstruct.CMSG_INIT, msgstruct.MAGIC)]
+    history = [page]
+    pagecache = {}
+
+    def getpage(graph_id):
+        page = history[graph_id]
+        try:
+            return pagecache[page]
+        except KeyError:
+            result = page.content()
+            pagecache.clear()    # a cache of a single entry should be enough
+            pagecache[page] = result
+            return result
+
+    def reload(graph_id):
+        page = getpage(graph_id)
+        messages.extend(page_messages(page, graph_id))
+        send_graph_messages(io, messages)
+        del messages[:]
+
+    io = spawn_handler()
+    reload(0)
+
+    if wait:
+        try:
+            while True:
+                msg = io.recvmsg()
+                # handle server-side messages
+                if msg[0] == msgstruct.MSG_RELOAD:
+                    graph_id = msg[1]
+                    pagecache.clear()
+                    reload(graph_id)
+                elif msg[0] == msgstruct.MSG_FOLLOW_LINK:
+                    graph_id = msg[1]
+                    word = msg[2]
+                    page = getpage(graph_id)
+                    try:
+                        page = page.followlink(word)
+                    except KeyError:
+                        io.sendmsg(msgstruct.CMSG_MISSING_LINK)
+                    else:
+                        # when following a link from an older page, assume that
+                        # we can drop the more recent history
+                        graph_id += 1
+                        history[graph_id:] = [page]
+                        reload(graph_id)
+        except EOFError:
+            pass
+        except Exception, e:
+            send_error(io, e)
+            raise
+        io.close()
+
+def page_messages(page, graph_id):
+    import graphparse
+    return graphparse.parse_dot(graph_id, page.source, page.links,
+                                getattr(page, 'fixedfont', False))
+
+def send_graph_messages(io, messages):
+    ioerror = None
+    for msg in messages:
+        try:
+            io.sendmsg(*msg)
+        except IOError, ioerror:
+            break
+    # wait for MSG_OK or MSG_ERROR
+    try:
+        while True:
+            msg = io.recvmsg()
+            if msg[0] == msgstruct.MSG_OK:
+                break
+    except EOFError:
+        ioerror = ioerror or IOError("connexion unexpectedly closed "
+                                     "(graphserver crash?)")
+    if ioerror is not None:
+        raise ioerror
+
+def send_error(io, e):
+    try:
+        errmsg = str(e)
+        if errmsg:
+            errmsg = '%s: %s' % (e.__class__.__name__, errmsg)
+        else:
+            errmsg = '%s' % (e.__class__.__name__,)
+        io.sendmsg(msgstruct.CMSG_SAY, errmsg)
+    except Exception:
+        pass
+
+def spawn_handler():
+    gsvar = os.environ.get('GRAPHSERVER')
+    if not gsvar:
+        return spawn_local_handler()
+    else:
+        try:
+            host, port = gsvar.split(':')
+            host = host or '127.0.0.1'
+            port = int(port)
+        except ValueError:
+            raise ValueError("$GRAPHSERVER must be set to HOST:PORT, got %r" %
+                             (gvvar,))
+        import socket
+        s = socket.socket()
+        s.connect((host, port))
+        return msgstruct.SocketIO(s)
+
+def spawn_local_handler():
+    cmdline = '"%s" -u "%s" --stdio' % (sys.executable, GRAPHSERVER)
+    child_in, child_out = os.popen2(cmdline, 'tb', 0)
+    io = msgstruct.FileIO(child_out, child_in)
+    return io

dotviewer/graphdisplay.py

+from __future__ import generators
+import os, time, sys
+import pygame
+from pygame.locals import *
+from drawgraph import GraphRenderer, FIXEDFONT
+from drawgraph import Node, Edge
+from drawgraph import EventQueue, wait_for_events
+
+
+METAKEYS = dict([
+    (ident[len('KMOD_'):].lower(), getattr(pygame.locals, ident))
+    for ident in dir(pygame.locals) if ident.startswith('KMOD_') and ident != 'KMOD_NONE'
+])
+
+if sys.platform == 'darwin':
+    PMETA = 'lmeta', 'rmeta'
+else:
+    PMETA = 'lalt', 'ralt', 'lctrl', 'rctrl'
+
+METAKEYS['meta'] = PMETA
+METAKEYS['shift'] = 'lshift', 'rshift'
+
+KEYS = dict([
+    (ident[len('K_'):].lower(), getattr(pygame.locals, ident))
+    for ident in dir(pygame.locals) if ident.startswith('K_')
+])
+
+KEYS['plus'] = ('=', '+', '.')
+KEYS['quit'] = ('q', 'escape')
+KEYS['help'] = ('h', '?', 'f1')
+
+def GET_KEY(key):
+    if len(key) == 1:
+        return key
+    return KEYS[key]
+
+def permute_mods(base, args):
+    if not args:
+        yield base
+        return
+    first, rest = args[0], args[1:]
+    for val in first:
+        for rval in permute_mods(base | val, rest):
+            yield rval
+
+class Display(object):
+
+    def __init__(self, (w,h)=(800,680)):
+        # initialize the modules by hand, to avoid initializing too much
+        # (e.g. the sound system)
+        pygame.display.init()
+        pygame.font.init()
+        self.resize((w,h))
+
+    def resize(self, (w,h)):
+        self.width = w
+        self.height = h
+        self.screen = pygame.display.set_mode((w, h), HWSURFACE|RESIZABLE, 32)
+
+class GraphDisplay(Display):
+    STATUSBARFONT = FIXEDFONT
+    ANIM_STEP = 0.03
+    KEY_REPEAT = (500, 30)
+    STATUSBAR_ALPHA = 0.75
+    STATUSBAR_FGCOLOR = (255, 255, 80)
+    STATUSBAR_BGCOLOR = (128, 0, 0)
+    STATUSBAR_OVERFLOWCOLOR = (255, 0, 0)
+    HELP_ALPHA = 0.95
+    HELP_FGCOLOR = (255, 255, 80)
+    HELP_BGCOLOR = (0, 128, 0)
+    INPUT_ALPHA = 0.75
+    INPUT_FGCOLOR = (255, 255, 80)
+    INPUT_BGCOLOR = (0, 0, 128)
+
+    KEYS = {
+        'meta -' : ('zoom', 0.5),
+             '-' : ('zoom', 0.5),
+        'meta plus' : ('zoom', 2.0),
+             'plus' : ('zoom', 2.0),
+        'meta 0' : 'zoom_actual_size',
+             '0' : 'zoom_actual_size',
+        'meta 1' : 'zoom_to_fit',
+             '1' : 'zoom_to_fit',
+        'meta f4' : 'quit',
+        'meta quit' : 'quit',
+             'quit' : 'quit',
+        'meta right' : 'layout_forward',
+        'meta left': 'layout_back',
+        'backspace' : 'layout_back',
+        'f': 'search',
+        '/': 'search',
+        'n': 'find_next',
+        'p': 'find_prev',
+        'r': 'reload',
+        'left' : ('pan', (-1, 0)),
+        'right' : ('pan', (1, 0)),
+        'up' : ('pan', (0, -1)),
+        'down' : ('pan', (0, 1)),
+        'shift left' : ('fast_pan', (-1, 0)),
+        'shift right' : ('fast_pan', (1, 0)),
+        'shift up' : ('fast_pan', (0, -1)),
+        'shift down' : ('fast_pan', (0, 1)),
+        'help': 'help',
+        'space': 'hit',
+    }
+
+    HELP_MSG = """
+    Key bindings:
+
+        +, = or .       Zoom in
+        -               Zoom out
+        1               Zoom to fit
+        0               Actual size
+
+        Arrows          Scroll
+        Shift+Arrows    Scroll faster
+
+        Space           Follow word link
+
+        Backspace       Go back in history
+        Meta Left       Go back in history
+        Meta Right      Go forward in history
+        R               Reload the page
+
+        F or /          Search for text
+        N               Find next occurrence
+        P               Find previous occurrence
+
+        F1, H or ?      This help message
+
+        Q or Esc        Quit
+
+    Mouse bindings:
+
+        Click on objects to move around
+        Drag with the left mouse button to zoom in/out
+        Drag with the right mouse button to scroll
+    """.replace('\n    ', '\n').strip()  # poor man's dedent
+
+
+    def __init__(self, layout):
+        super(GraphDisplay, self).__init__()
+        self.font = pygame.font.Font(self.STATUSBARFONT, 16)
+        self.viewers_history = []
+        self.forward_viewers_history = []
+        self.highlight_word = None
+        self.highlight_obj = None
+        self.viewer = None
+        self.method_cache = {}
+        self.key_cache = {}
+        self.ascii_key_cache = {}
+        self.status_bar_height = 0
+        self.searchstr = None
+        self.searchpos = 0
+        self.searchresults = []
+        self.initialize_keys()
+        self.setlayout(layout)
+
+    def initialize_keys(self):
+        pygame.key.set_repeat(*self.KEY_REPEAT)
+        
+        mask = 0
+
+        for strnames, methodname in self.KEYS.iteritems():
+            names = strnames.split()
+            if not isinstance(methodname, basestring):
+                methodname, args = methodname[0], methodname[1:]
+            else:
+                args = ()
+            method = getattr(self, methodname, None)
+            if method is None:
+                print 'Can not implement key mapping %r, %s.%s does not exist' % (
+                        strnames, self.__class__.__name__, methodname)
+                continue
+
+            mods = []
+            basemod = 0
+            keys = []
+            for name in names:
+                if name in METAKEYS:
+                    val = METAKEYS[name]
+                    if not isinstance(val, int):
+                        mods.append(tuple([METAKEYS[k] for k in val]))
+                    else:
+                        basemod |= val
+                else:
+                    val = GET_KEY(name)
+                    assert len(keys) == 0
+                    if not isinstance(val, (int, basestring)):
+                        keys.extend([GET_KEY(k) for k in val])
+                    else:
+                        keys.append(val)
+            assert keys
+            for key in keys:
+                if isinstance(key, int):
+                    for mod in permute_mods(basemod, mods):
+                        self.key_cache[(key, mod)] = (method, args)
+                        mask |= mod
+                else:
+                    for mod in permute_mods(basemod, mods):
+                        char = key.lower()
+                        mod = mod & ~KMOD_SHIFT
+                        self.ascii_key_cache[(char, mod)] = (method, args)
+                        mask |= mod
+            
+        self.key_mask = mask
+
+    def help(self):
+        """Show a help window and wait for a key or a mouse press."""
+        margin_x = margin_y = 64
+        padding_x = padding_y = 8
+        fgcolor = self.HELP_FGCOLOR
+        bgcolor = self.HELP_BGCOLOR
+        helpmsg = self.HELP_MSG
+        width = self.width - 2*margin_x
+        height = self.height - 2*margin_y
+        lines = rendertext(helpmsg, self.font, fgcolor, width - 2*padding_x,
+                           height - 2*padding_y)
+        block = pygame.Surface((width, height), SWSURFACE | SRCALPHA)
+        block.fill(bgcolor)
+        sx = padding_x
+        sy = padding_y
+        for img in lines:
+            w, h = img.get_size()
+            block.blit(img, (sx, sy))
+            sy += h
+        block.set_alpha(int(255 * self.HELP_ALPHA))
+        self.screen.blit(block, (margin_x, margin_y))
+
+        pygame.display.flip()
+        while True:
+            wait_for_events()
+            e = EventQueue.pop(0)
+            if e.type in (MOUSEBUTTONDOWN, KEYDOWN, QUIT):
+                break
+        if e.type == QUIT:
+            EventQueue.insert(0, e)   # re-insert a QUIT
+        self.must_redraw = True
+
+    def input(self, prompt):
+        """Ask the user to input something.
+
+        Returns the string that the user entered, or None if the user pressed
+        Esc.
+        """
+
+        def draw(text):
+            margin_x = margin_y = 0
+            padding_x = padding_y = 8
+            fgcolor = self.INPUT_FGCOLOR
+            bgcolor = self.INPUT_BGCOLOR
+            width = self.width - 2*margin_x
+            lines = renderline(text, self.font, fgcolor, width - 2*padding_x)
+            height = totalheight(lines) + 2 * padding_y
+            block = pygame.Surface((width, height), SWSURFACE | SRCALPHA)
+            block.fill(bgcolor)
+            sx = padding_x
+            sy = padding_y
+            for img in lines:
+                w, h = img.get_size()
+                block.blit(img, (sx, sy))
+                sy += h
+            block.set_alpha(int(255 * self.INPUT_ALPHA))
+            # This can be slow.  It would be better to take a screenshot
+            # and use it as the background.
+            self.viewer.render()
+            if self.statusbarinfo:
+                self.drawstatusbar()
+            self.screen.blit(block, (margin_x, margin_y))
+            pygame.display.flip()
+
+        draw(prompt)
+        text = ""
+        self.must_redraw = True
+        while True:
+            wait_for_events()
+            old_text = text
+            events = EventQueue[:]
+            del EventQueue[:]
+            for e in events:
+                if e.type == QUIT:
+                    EventQueue.insert(0, e)   # re-insert a QUIT
+                    return None
+                elif e.type == KEYDOWN:
+                    if e.key == K_ESCAPE:
+                        return None
+                    elif e.key == K_RETURN:
+                        return text.encode('latin-1')   # XXX do better
+                    elif e.key == K_BACKSPACE:
+                        text = text[:-1]
+                    elif e.unicode and ord(e.unicode) >= ord(' '):
+                        text += e.unicode
+            if text != old_text:
+                draw(prompt + text)
+
+    def hit(self):
+        word = self.highlight_word
+        if word is not None:
+            if word in self.layout.links:
+                self.setstatusbar('loading...')
+                self.redraw_now()
+                self.layout.request_followlink(word)
+
+
+    def search(self):
+        searchstr = self.input('Find: ')
+        if not searchstr:
+            return
+        self.searchstr = searchstr
+        self.searchpos = -1
+        self.searchresults = list(self.viewer.findall(self.searchstr))
+        self.find_next()
+
+    def find_next(self):
+        if not self.searchstr:
+            return
+        if self.searchpos + 1 >= len(self.searchresults):
+            self.setstatusbar('Not found: %s' % self.searchstr)
+            return
+        self.searchpos += 1
+        self.highlight_found_item()
+
+    def find_prev(self):
+        if not self.searchstr:
+            return
+        if self.searchpos - 1 < 0:
+            self.setstatusbar('Not found: %s' % self.searchstr)
+            return
+        self.searchpos -= 1
+        self.highlight_found_item()
+
+    def highlight_found_item(self):
+        item = self.searchresults[self.searchpos]
+        self.sethighlight(obj=item)
+        msg = 'Found %%s containing %s (%d/%d)' % (
+                        self.searchstr.replace('%', '%%'),
+                        self.searchpos+1, len(self.searchresults))
+        if isinstance(item, Node):
+            self.setstatusbar(msg % 'node')
+            self.look_at_node(item, keep_highlight=True)
+        elif isinstance(item, Edge):
+            self.setstatusbar(msg % 'edge')
+            self.look_at_edge(item, keep_highlight=True)
+        else:
+            # should never happen
+            self.setstatusbar(msg % item)
+
+    def setlayout(self, layout):
+        if self.viewer and getattr(self.viewer.graphlayout, 'key', True) is not None:
+            self.viewers_history.append(self.viewer)
+            del self.forward_viewers_history[:]
+        self.layout = layout
+        self.viewer = GraphRenderer(self.screen, layout)
+        self.searchpos = 0
+        self.searchresults = []
+        self.zoom_to_fit()
+
+    def zoom_actual_size(self):
+        self.viewer.shiftscale(float(self.viewer.SCALEMAX) / self.viewer.scale)
+        self.updated_viewer()
+
+    def calculate_zoom_to_fit(self):
+        return min(float(self.width) / self.viewer.width,
+                float(self.height) / self.viewer.height,
+                float(self.viewer.SCALEMAX) / self.viewer.scale)
+    
+    def zoom_to_fit(self):
+        """
+        center and scale to view the whole graph
+        """
+
+        f = self.calculate_zoom_to_fit()
+        self.viewer.shiftscale(f)
+        self.updated_viewer()
+
+    def zoom(self, scale):
+        self.viewer.shiftscale(max(scale, self.calculate_zoom_to_fit()))
+        self.updated_viewer()
+
+    def reoffset(self):
+        self.viewer.reoffset(self.width, self.height)
+    
+    def pan(self, (x, y)):
+        self.viewer.shiftoffset(x * (self.width // 8), y * (self.height // 8))
+        self.updated_viewer()
+
+    def fast_pan(self, (x, y)):
+        self.pan((x * 4, y * 4))
+    
+    def update_status_bar(self):
+        self.statusbarinfo = None
+        self.must_redraw = True
+        if self.viewers_history:
+            info = 'Press Backspace to go back to previous screen'
+        else:
+            info = 'Press H for help'
+        self.setstatusbar(info)
+    
+    def updated_viewer(self, keep_highlight=False):
+        self.reoffset()
+        if not keep_highlight:
+            self.sethighlight()
+            self.update_status_bar()
+        self.must_redraw = True
+
+    def layout_back(self):
+        if self.viewers_history:
+            self.forward_viewers_history.append(self.viewer)
+            self.viewer = self.viewers_history.pop()
+            self.layout = self.viewer.graphlayout
+            self.updated_viewer()
+
+    def layout_forward(self):
+        if self.forward_viewers_history:
+            self.viewers_history.append(self.viewer)
+            self.viewer = self.forward_viewers_history.pop()
+            self.layout = self.viewer.graphlayout
+            self.updated_viewer()
+
+    def reload(self):
+        self.setstatusbar('reloading...')
+        self.redraw_now()
+        self.layout.request_reload()
+
+    def setstatusbar(self, text, fgcolor=None, bgcolor=None):
+        info = (text, fgcolor or self.STATUSBAR_FGCOLOR, bgcolor or self.STATUSBAR_BGCOLOR)
+        if info != self.statusbarinfo:
+            self.statusbarinfo = info
+            self.must_redraw = True
+
+    def drawstatusbar(self):
+        text, fgcolor, bgcolor = self.statusbarinfo
+        maxheight = self.height / 2
+        lines = rendertext(text, self.font, fgcolor, self.width, maxheight,
+                           self.STATUSBAR_OVERFLOWCOLOR)
+        totalh = totalheight(lines)
+        y = self.height - totalh
+        self.status_bar_height = totalh + 16
+        block = pygame.Surface((self.width, self.status_bar_height), SWSURFACE | SRCALPHA)
+        block.fill(bgcolor)
+        sy = 16
+        for img in lines:
+            w, h = img.get_size()
+            block.blit(img, ((self.width-w)//2, sy-8))
+            sy += h
+        block.set_alpha(int(255 * self.STATUSBAR_ALPHA))
+        self.screen.blit(block, (0, y-16))
+
+    def notifymousepos(self, pos):
+        word = self.viewer.at_position(pos)
+        if word in self.layout.links:
+            info = self.layout.links[word]
+            if isinstance(info, tuple):
+                info = info[0]
+            self.setstatusbar(info)
+            self.sethighlight(word)
+            return
+        node = self.viewer.node_at_position(pos)
+        if node:
+            self.setstatusbar(shortlabel(node.label))
+            self.sethighlight(obj=node)
+            return
+        edge = self.viewer.edge_at_position(pos)
+        if edge:
+            info = '%s -> %s' % (shortlabel(edge.tail.label),
+                                 shortlabel(edge.head.label))
+            if edge.label:
+                info += '\n' + shortlabel(edge.label)
+            self.setstatusbar(info)
+            self.sethighlight(obj=edge)
+            return
+        self.sethighlight()
+
+    def notifyclick(self, pos):
+        word = self.viewer.at_position(pos)
+        if word in self.layout.links:
+            self.setstatusbar('loading...')
+            self.redraw_now()
+            self.layout.request_followlink(word)
+            return
+        node = self.viewer.node_at_position(pos)
+        if node:
+            self.look_at_node(node)
+        else:
+            edge = self.viewer.edge_at_position(pos)
+            if edge:
+                if (self.distance_to_node(edge.head) >=
+                    self.distance_to_node(edge.tail)):
+                    self.look_at_node(edge.head)
+                else:
+                    self.look_at_node(edge.tail)
+
+    def sethighlight(self, word=None, obj=None):
+        if word == self.highlight_word and obj is self.highlight_obj:
+            return # Nothing has changed, so there's no need to redraw
+
+        self.viewer.highlight_word = word
+        if self.highlight_obj is not None:
+            self.highlight_obj.sethighlight(False)
+        if obj is not None:
+            obj.sethighlight(True)
+        self.highlight_word = word
+        self.highlight_obj = obj
+        self.must_redraw = True
+
+    def animation(self, expectedtime=0.6):
+        start = time.time()
+        step = 0.0
+        n = 0
+        while True:
+            step += self.ANIM_STEP
+            if step >= expectedtime:
+                break
+            yield step / expectedtime
+            n += 1
+            now = time.time()
+            frametime = (now-start) / n
+            self.ANIM_STEP = self.ANIM_STEP * 0.9 + frametime * 0.1
+        yield 1.0
+
+    def distance_to_node(self, node):
+        cx1, cy1 = self.viewer.getcenter()
+        cx2, cy2 = node.x, node.y
+        return (cx2-cx1)*(cx2-cx1) + (cy2-cy1)*(cy2-cy1)
+
+    def look_at_node(self, node, keep_highlight=False):
+        """Shift the node in view."""
+        self.look_at(node.x, node.y, node.w, node.h, keep_highlight)
+
+    def look_at_edge(self, edge, keep_highlight=False):
+        """Shift the edge's label into view."""
+        points = edge.bezierpoints()
+        xmin = min([x for (x, y) in points])
+        xmax = max([x for (x, y) in points])
+        ymin = min([y for (x, y) in points])
+        ymax = max([y for (x, y) in points])
+        x = (xmin + xmax) / 2
+        y = (ymin + ymax) / 2
+        w = max(1, xmax - xmin)
+        h = max(1, ymax - ymin)
+        self.look_at(x, y, w, h, keep_highlight)
+
+    def look_at(self, targetx, targety, targetw, targeth,
+                     keep_highlight=False):
+        """Shift the node in view."""
+        endscale = min(float(self.width-40) / targetw,
+                       float(self.height-40) / targeth,
+                       75)
+        startscale = self.viewer.scale
+        cx1, cy1 = self.viewer.getcenter()
+        cx2, cy2 = targetx, targety
+        moving = (abs(startscale-endscale) + abs(cx1-cx2) + abs(cy1-cy2)
+                  > 0.4)
+        if moving:
+            # if the target is far off the window, reduce scale along the way
+            tx, ty = self.viewer.map(cx2, cy2)
+            offview = max(-tx, -ty, tx-self.width, ty-self.height)
+            middlescale = endscale * (0.999 ** offview)
+            if offview > 150 and middlescale < startscale:
+                bumpscale = 4.0 * (middlescale - 0.5*(startscale+endscale))
+            else:
+                bumpscale = 0.0
+            if not keep_highlight:
+                self.statusbarinfo = None
+                self.sethighlight()
+            for t in self.animation():
+                self.viewer.setscale(startscale*(1-t) + endscale*t +
+                                     bumpscale*t*(1-t))
+                self.viewer.setcenter(cx1*(1-t) + cx2*t, cy1*(1-t) + cy2*t)
+                self.updated_viewer(keep_highlight=keep_highlight)
+                self.redraw_now()
+        return moving
+
+    def peek(self, typ):
+        for event in EventQueue:
+            if event.type == typ:
+                return True
+        return False
+
+    def process_event(self, event):
+        method = self.method_cache.get(event.type, KeyError)
+        if method is KeyError:
+            method = getattr(self, 'process_%s' % (pygame.event.event_name(event.type),), None)
+            self.method_cache[method] = method
+        if method is not None:
+            method(event)
+        
+    def process_MouseMotion(self, event):
+        if self.peek(MOUSEMOTION):
+            return
+        if self.dragging:
+            if (abs(event.pos[0] - self.click_origin[0]) +
+                abs(event.pos[1] - self.click_origin[1])) > 12:
+                self.click_time = None
+            dx = event.pos[0] - self.dragging[0]
+            dy = event.pos[1] - self.dragging[1]
+            if event.buttons[0]:   # left mouse button
+                self.zoom(1.003 ** (dx+dy))
+            else:
+                self.viewer.shiftoffset(-2*dx, -2*dy)
+                self.updated_viewer()
+            self.dragging = event.pos
+            self.must_redraw = True
+        else:
+            self.notifymousepos(event.pos)
+
+    def process_MouseButtonDown(self, event):
+        self.dragging = self.click_origin = event.pos
+        self.click_time = time.time()
+#        pygame.event.set_grab(True)
+
+    def process_MouseButtonUp(self, event):
+        self.dragging = None
+        pygame.event.set_grab(False)
+        if self.click_time is not None and abs(time.time() - self.click_time) < 1:
+            # click (no significant dragging)
+            self.notifyclick(self.click_origin)
+            self.click_time = None
+        else:
+            self.update_status_bar()
+            self.click_time = None
+            self.notifymousepos(event.pos)
+
+    def process_KeyDown(self, event):
+        mod = event.mod & self.key_mask
+        method, args = self.key_cache.get((event.key, mod), (None, None))
+        if method is None and event.unicode:
+            char = event.unicode.lower()
+            mod = mod & ~ KMOD_SHIFT
+            method, args = self.ascii_key_cache.get((char, mod), (None, None))
+        if method is not None:
+            method(*args)
+
+    def process_VideoResize(self, event):
+        # short-circuit if there are more resize events pending
+        if self.peek(VIDEORESIZE):
+            return
+        # XXX sometimes some jerk are trying to minimise our window,
+        # discard such event (we see a height of 5 in this case).
+        # XXX very specific MacOS/X workaround: after resizing the window
+        # to a height of 1 and back, we get two bogus VideoResize events,
+        # for height 16 and 32.
+        # XXX summary: let's ignore all resize events with a height <= 32
+        if event.size[1] <= 32:
+            return
+        self.resize(event.size)
+        self.must_redraw = True
+
+    def process_Quit(self, event):
+        self.quit()
+     
+    def process_UserEvent(self, event): # new layout request
+        if hasattr(event, 'layout'):
+            if event.layout is None:
+                self.setstatusbar('cannot follow this link')
+            else:
+                self.setlayout(event.layout)
+        elif hasattr(event, 'say'):
+            self.setstatusbar(event.say)
+    
+    def quit(self):
+        raise StopIteration
+
+    def redraw_now(self):
+        self.viewer.render()
+        if self.statusbarinfo:
+            self.drawstatusbar()
+        else:
+            self.status_bar_height = 0
+        pygame.display.flip()
+        self.must_redraw = False
+
+    def run1(self):
+        self.dragging = self.click_origin = self.click_time = None
+        try:
+
+            while True:
+
+                if self.must_redraw and not EventQueue:
+                    self.redraw_now()
+
+                if not EventQueue:
+                    wait_for_events()
+
+                self.process_event(EventQueue.pop(0))
+
+        except StopIteration:
+            pass
+
+    def run(self):
+        self.run1()
+        # cannot safely close and re-open the display, depending on
+        # Pygame version and platform.
+        pygame.display.set_mode((self.width,1))
+
+
+def shortlabel(label):
+    """Shorten a graph node label."""
+    return label and label.replace('\\l', '\n').splitlines()[0]
+
+
+def renderline(text, font, fgcolor, width, maxheight=sys.maxint,
+               overflowcolor=None):
+    """Render a single line of text into a list of images.
+
+    Performs word wrapping.
+    """
+    if overflowcolor is None:
+        overflowcolor = fgcolor
+    words = text.split(' ')
+    lines = []
+    while words:
+        line = words.pop(0)
+        img = font.render(line or ' ', 1, fgcolor)
+        while words:
+            longerline = line + ' ' + words[0]
+            longerimg = font.render(longerline, 1, fgcolor)
+            w, h = longerimg.get_size()
+            if w > width:
+                break
+            words.pop(0)
+            line = longerline
+            img = longerimg
+        w, h = img.get_size()
+        if h > maxheight:
+            img = font.render('...', 1, overflowcolor)
+            w, h = img.get_size()
+            while lines and h > maxheight:
+                maxheight += lines.pop().get_size()[1]
+            lines.append(img)
+            break
+        maxheight -= h
+        lines.append(img)
+    return lines
+
+
+def rendertext(text, font, fgcolor, width, maxheight=sys.maxint,
+               overflowcolor=None):
+    """Render a multiline string into a list of images.
+
+    Performs word wrapping for each line individually."""
+    lines = []
+    for line in text.splitlines():
+        l = renderline(line, font, fgcolor, width, maxheight, overflowcolor)
+        lines.extend(l)
+        maxheight -= totalheight(l)
+        if maxheight <= 0:
+            break
+    return lines
+
+
+def totalheight(lines):
+    """Calculate the total height of a list of images."""
+    totalh = 0
+    for img in lines:
+        w, h = img.get_size()
+        totalh += h
+    return totalh

dotviewer/graphpage.py

+
+class GraphPage(object):
+    """Base class for the client-side content of one of the 'pages'
+    (one graph) sent over to and displayed by the external process.
+    """
+    def __init__(self, *args):
+        self.args = args
+
+    def content(self):
+        """Compute the content of the page.
+        This doesn't modify the page in place; it returns a new GraphPage.
+        """
+        if hasattr(self, 'source'):
+            return self
+        else:
+            new = self.__class__()
+            new.source = ''  # '''dot source'''
+            new.links  = {}  # {'word': 'statusbar text'}
+            new.compute(*self.args)   # defined in subclasses
+            return new
+
+    def followlink(self, word):
+        raise KeyError
+
+    def display(self):
+        "Display a graph page."
+        import graphclient
+        graphclient.display_page(self)
+
+    def display_background(self):
+        "Display a graph page in a background thread."
+        import graphclient, thread
+        thread.start_new_thread(graphclient.display_page, (self,))
+
+
+class DotFileGraphPage(GraphPage):
+    def compute(self, dotfile):
+        f = open(dotfile, 'r')
+        self.source = f.read()
+        f.close()

dotviewer/graphparse.py

+"""
+Graph file parsing.
+"""
+
+import os, sys, re, thread
+import msgstruct
+
+re_nonword = re.compile(r'([^0-9a-zA-Z_.]+)')
+
+
+def dot2plain(content, use_codespeak=False):
+    if content.startswith('graph '):
+        # already a .plain file
+        return content
+
+    if not use_codespeak:
+        # try to see whether it is a directed graph or not:
+        if "digraph" in content:
+            cmdline = 'dot -Tplain'
+        else:
+            cmdline = 'neato -Tplain'
+        #print >> sys.stderr, '* running:', cmdline
+        child_in, child_out = os.popen2(cmdline, 'r')
+        thread.start_new_thread(bkgndwrite, (child_in, content))
+        plaincontent = child_out.read()
+        child_out.close()
+        if not plaincontent:    # 'dot' is likely not installed
+            raise PlainParseError("no result from running 'dot'")
+    else:
+        import urllib
+        request = urllib.urlencode({'dot': content})
+        url = 'http://codespeak.net/pypy/convertdot.cgi'
+        print >> sys.stderr, '* posting:', url
+        g = urllib.urlopen(url, data=request)
+        result = []
+        while True:
+            data = g.read(16384)
+            if not data:
+                break
+            result.append(data)
+        g.close()
+        plaincontent = ''.join(result)
+        # very simple-minded way to give a somewhat better error message
+        if plaincontent.startswith('<body'):
+            raise Exception("the dot on codespeak has very likely crashed")
+    return plaincontent
+
+def bkgndwrite(f, data):
+    f.write(data)
+    f.close()
+
+class PlainParseError(Exception):
+    pass
+
+def splitline(line, re_word = re.compile(r'[^\s"]\S*|["]["]|["].*?[^\\]["]')):
+    result = []
+    for word in re_word.findall(line):
+        if word.startswith('"'):
+            word = eval(word)
+        result.append(word)
+    return result
+
+def parse_plain(graph_id, plaincontent, links={}, fixedfont=False):
+    lines = plaincontent.splitlines(True)
+    for i in range(len(lines)-2, -1, -1):
+        if lines[i].endswith('\\\n'):   # line ending in '\'
+            lines[i] = lines[i][:-2] + lines[i+1]
+            del lines[i+1]
+    header = splitline(lines.pop(0))
+    if header[0] != 'graph':
+        raise PlainParseError("should start with 'graph'")
+    yield (msgstruct.CMSG_START_GRAPH, graph_id) + tuple(header[1:])
+
+    texts = []
+    for line in lines:
+        line = splitline(line)
+        if line[0] == 'node':
+            if len(line) != 11:
+                raise PlainParseError("bad 'node'")
+            yield (msgstruct.CMSG_ADD_NODE,) + tuple(line[1:])
+            texts.append(line[6])
+        if line[0] == 'edge':
+            yield (msgstruct.CMSG_ADD_EDGE,) + tuple(line[1:])
+            i = 4 + 2 * int(line[3])
+            if len(line) > i + 2:
+                texts.append(line[i])
+        if line[0] == 'stop':
+            break
+
+    if links:
+        # only include the links that really appear in the graph
+        seen = {}
+        for text in texts:
+            for word in re_nonword.split(text):
+                if word and word in links and word not in seen:
+                    t = links[word]
+                    if isinstance(t, tuple):
+                        statusbartext, color = t
+                    else:
+                        statusbartext = t
+                        color = None
+                    if color is not None:
+                        yield (msgstruct.CMSG_ADD_LINK, word,
+                               statusbartext, color[0], color[1], color[2])
+                    else:
+                        yield (msgstruct.CMSG_ADD_LINK, word, statusbartext)
+                    seen[word] = True
+
+    if fixedfont:
+        yield (msgstruct.CMSG_FIXED_FONT,)
+
+    yield (msgstruct.CMSG_STOP_GRAPH,)
+
+def parse_dot(graph_id, content, links={}, fixedfont=False):
+    try:
+        plaincontent = dot2plain(content, use_codespeak=False)
+        return list(parse_plain(graph_id, plaincontent, links, fixedfont))
+    except PlainParseError:
+        # failed, retry via codespeak
+        plaincontent = dot2plain(content, use_codespeak=True)
+        return list(parse_plain(graph_id, plaincontent, links, fixedfont))

dotviewer/graphserver.py

+#! /usr/bin/env python
+"""
+Usage:
+    graphserver.py  <port number>
+
+Start a server listening for connexions on the given port.
+"""
+
+import sys
+import msgstruct
+from cStringIO import StringIO
+
+
+class Server(object):
+
+    def __init__(self, io):
+        self.io = io
+        self.display = None
+
+    def run(self, only_one_graph=False):
+        # wait for the CMSG_INIT message
+        msg = self.io.recvmsg()
+        if msg[0] != msgstruct.CMSG_INIT or msg[1] != msgstruct.MAGIC:
+            raise ValueError("bad MAGIC number")
+        # process messages until we have a pygame display
+        while self.display is None:
+            self.process_next_message()
+        # start a background thread to process further messages
+        if not only_one_graph:
+            import thread
+            thread.start_new_thread(self.process_all_messages, ())
+        # give control to pygame
+        self.display.run1()
+
+    def process_all_messages(self):
+        try:
+            while True:
+                self.process_next_message()
+        except EOFError:
+            from drawgraph import display_async_quit
+            display_async_quit()
+
+    def process_next_message(self):
+        msg = self.io.recvmsg()
+        fn = self.MESSAGES.get(msg[0])
+        if fn:
+            fn(self, *msg[1:])
+        else:
+            self.log("unknown message code %r" % (msg[0],))
+
+    def log(self, info):
+        print >> sys.stderr, info
+
+    def setlayout(self, layout):
+        if self.display is None:
+            # make the initial display
+            from graphdisplay import GraphDisplay
+            self.display = GraphDisplay(layout)
+        else:
+            # send an async command to the display running the main thread
+            from drawgraph import display_async_cmd
+            display_async_cmd(layout=layout)
+
+    def cmsg_start_graph(self, graph_id, scale, width, height, *rest):
+        from drawgraph import GraphLayout
+        self.newlayout = GraphLayout(float(scale), float(width), float(height))
+
+        def request_reload():
+            self.io.sendmsg(msgstruct.MSG_RELOAD, graph_id)
+        def request_followlink(word):
+            self.io.sendmsg(msgstruct.MSG_FOLLOW_LINK, graph_id, word)
+
+        self.newlayout.request_reload = request_reload
+        self.newlayout.request_followlink = request_followlink
+
+    def cmsg_add_node(self, *args):
+        self.newlayout.add_node(*args)
+
+    def cmsg_add_edge(self, *args):
+        self.newlayout.add_edge(*args)
+
+    def cmsg_add_link(self, word, *info):
+        if len(info) == 1:
+            info = info[0]
+        elif len(info) >= 4:
+            info = (info[0], info[1:4])
+        self.newlayout.links[word] = info
+
+    def cmsg_fixed_font(self, *rest):
+        self.newlayout.fixedfont = True
+
+    def cmsg_stop_graph(self, *rest):
+        self.setlayout(self.newlayout)
+        del self.newlayout
+        self.io.sendmsg(msgstruct.MSG_OK)
+
+    def cmsg_missing_link(self, *rest):
+        self.setlayout(None)
+
+    def cmsg_say(self, errmsg, *rest):
+        from drawgraph import display_async_cmd
+        display_async_cmd(say=errmsg)
+
+    MESSAGES = {
+        msgstruct.CMSG_START_GRAPH: cmsg_start_graph,
+        msgstruct.CMSG_ADD_NODE:    cmsg_add_node,
+        msgstruct.CMSG_ADD_EDGE:    cmsg_add_edge,
+        msgstruct.CMSG_ADD_LINK:    cmsg_add_link,
+        msgstruct.CMSG_FIXED_FONT:  cmsg_fixed_font,
+        msgstruct.CMSG_STOP_GRAPH:  cmsg_stop_graph,
+        msgstruct.CMSG_MISSING_LINK:cmsg_missing_link,
+        msgstruct.CMSG_SAY:         cmsg_say,
+        }
+
+
+def listen_server(local_address):
+    import socket, graphclient, thread
+    if isinstance(local_address, str):
+        if ':' in local_address:
+            interface, port = local_address.split(':')
+        else:
+            interface, port = '', local_address
+        local_address = interface, int(port)
+    s1 = socket.socket()
+    s1.bind(local_address)
+    s1.listen(5)
+    print 'listening on %r...' % (s1.getsockname(),)
+    while True:
+        conn, addr = s1.accept()
+        print 'accepted connexion from %r' % (addr,)
+        sock_io = msgstruct.SocketIO(conn)
+        handler_io = graphclient.spawn_local_handler()
+        thread.start_new_thread(copy_all, (sock_io, handler_io))
+        thread.start_new_thread(copy_all, (handler_io, sock_io))
+        del sock_io, handler_io, conn
+
+def copy_all(io1, io2):
+    try:
+        while True:
+            io2.sendall(io1.recv())
+    except EOFError:
+        io2.close_sending()
+
+
+if __name__ == '__main__':
+    if len(sys.argv) != 2:
+        print >> sys.stderr, __doc__
+        sys.exit(2)
+    if sys.argv[1] == '--stdio':
+        # a one-shot server running on stdin/stdout
+        io = msgstruct.FileIO(sys.stdin, sys.stdout)
+        srv = Server(io)
+        try:
+            srv.run()
+        except Exception, e:
+            import traceback
+            f = StringIO()
+            traceback.print_exc(file=f)
+            # try to add some explanations
+            help = (" | if you want to debug on a remote machine, set the\n"
+                    " | GRAPHSERVER env var to a HOSTNAME:PORT pointing\n"
+                    " | back to a locally running graphserver.py.")
+            try:
+                import pygame
+            except ImportError:
+                f.seek(0)
+                f.truncate()
+                print >> f, "ImportError"
+                print >> f, " | Pygame is not installed; either install it, or"
+                print >> f, help
+            else:
+                if isinstance(e, pygame.error):
+                    print >> f, help
+            io.sendmsg(msgstruct.MSG_ERROR, f.getvalue())
+    else:
+        listen_server(sys.argv[1])

dotviewer/msgstruct.py

+import sys, os
+from struct import pack, unpack, calcsize
+
+MAGIC = -0x3b83728b
+
+CMSG_INIT        = 'i'
+CMSG_START_GRAPH = '['
+CMSG_ADD_NODE    = 'n'
+CMSG_ADD_EDGE    = 'e'
+CMSG_ADD_LINK    = 'l'
+CMSG_FIXED_FONT  = 'f'
+CMSG_STOP_GRAPH  = ']'
+CMSG_MISSING_LINK= 'm'
+CMSG_SAY         = 's'
+
+MSG_OK           = 'O'
+MSG_ERROR        = 'E'
+MSG_RELOAD       = 'R'
+MSG_FOLLOW_LINK  = 'L'
+
+# ____________________________________________________________
+
+long_min = -2147483648
+long_max = 2147483647
+
+
+def message(tp, *values):
+    #print >> sys.stderr, tp, values
+    typecodes = ['']
+    for v in values:
+        if type(v) is str:
+            typecodes.append('%ds' % len(v))
+        elif 0 <= v < 256:
+            typecodes.append('B')
+        elif long_min <= v <= long_max:
+            typecodes.append('l')
+        else:
+            typecodes.append('q')
+    typecodes = ''.join(typecodes)
+    if len(typecodes) < 256:
+        return pack(("!B%dsc" % len(typecodes)) + typecodes,
+                    len(typecodes), typecodes, tp, *values)
+    else:
+        # too many values - encapsulate the message in another one
+        return message('\x00', typecodes, pack("!c" + typecodes, tp, *values))
+
+def decodemessage(data):
+    if data:
+        limit = ord(data[0]) + 1
+        if len(data) >= limit:
+            typecodes = "!c" + data[1:limit]
+            end = limit + calcsize(typecodes)
+            if len(data) >= end:
+                msg = unpack(typecodes, data[limit:end])
+                if msg[0] == '\x00':
+                    msg = unpack("!c" + msg[1], msg[2])
+                return msg, data[end:]
+            #elif end > 1000000:
+            #    raise OverflowError
+    return None, data
+
+# ____________________________________________________________
+
+class RemoteError(Exception):
+    pass
+
+
+class IO(object):
+    _buffer = ''
+
+    def sendmsg(self, tp, *values):
+        self.sendall(message(tp, *values))
+
+    def recvmsg(self):
+        while True:
+            msg, self._buffer = decodemessage(self._buffer)
+            if msg is not None:
+                break
+            self._buffer += self.recv()
+        if msg[0] != MSG_ERROR:
+            return msg
+        raise RemoteError(*msg[1:])
+
+
+class FileIO(IO):
+    def __init__(self, f_in, f_out):
+        if sys.platform == 'win32':
+            import msvcrt
+            msvcrt.setmode(f_in.fileno(), os.O_BINARY)
+            msvcrt.setmode(f_out.fileno(), os.O_BINARY)
+        self.f_in = f_in
+        self.f_out = f_out
+
+    def sendall(self, data):
+        self.f_out.write(data)
+
+    def recv(self):
+        fd = self.f_in.fileno()
+        data = os.read(fd, 16384)
+        if not data:
+            raise EOFError
+        return data
+
+    def close_sending(self):
+        self.f_out.close()
+
+    def close(self):
+        self.f_out.close()
+        self.f_in.close()
+
+
+class SocketIO(IO):
+    def __init__(self, s):
+        self.s = s
+
+    def sendall(self, data):
+        self.s.sendall(data)
+
+    def recv(self):
+        data = self.s.recv(16384)
+        if not data:
+            raise EOFError
+        return data
+
+    def close_sending(self):
+        self.s.shutdown(1)    # SHUT_WR
+
+    def close(self):
+        self.s.close()

dotviewer/test/__init__.py

Empty file added.

dotviewer/test/test_interactive.py

+import py
+import sys, os, signal, thread, time
+from dotviewer.conftest import option
+
+SOURCE1 = r'''digraph _generated__graph {
+subgraph _generated__ {
+_generated__ [shape="box", label="generated", color="black", fillcolor="#a5e6f0", style="filled", width="0.75"];
+edge [label="startblock", style="dashed", color="black", dir="forward", weight="5"];
+_generated__ -> _generated____1
+_generated____1 [shape="box", label="generated__1\ninputargs: v2720\n\n", color="black", fillcolor="white", style="filled", width="0.75"];
+edge [label="v2720", style="solid", color="black", dir="forward", weight="5"];
+_generated____1 -> _generated____2
+_generated____2 [shape="octagon", label="generated__2\ninputargs: v2721\n\nv2722 = int_gt(v2721, (2))\l\lexitswitch: v2722", color="red", fillcolor="white", style="filled", width="0.75"];
+edge [label="False: v2721", style="dotted", color="red", dir="forward", weight="5"];
+_generated____2 -> _generated____3
+edge [label="True: v2721", style="dotted", color="red", dir="forward", weight="5"];
+_generated____2 -> _generated____4
+_generated____3 [shape="octagon", label="generated__3\ninputargs: v2723\n\nv2724 = int_gt(v2723, (0))\l\lexitswitch: v2724", color="red", fillcolor="white", style="filled", width="0.75"];
+edge [label="False: (22) v2723", style="dotted", color="red", dir="forward", weight="5"];
+_generated____3 -> _generated____5
+edge [label="True: v2723 v2724", style="dotted", color="red", dir="forward", weight="5"];
+_generated____3 -> _generated____6
+_generated____5 [shape="box", label="generated__5\ninputargs: v2727 v2725\n\nv2726 = int_sub(v2725, (1))\lv2728 = int_add(v2727, (1))\lv2729 = int_add(v2728, v2726)\l", color="black", fillcolor="white", style="filled", width="0.75"];
+edge [label="v2729", style="solid", color="black", dir="forward", weight="5"];
+_generated____5 -> _generated____7
+_generated____7 [shape="box", label="generated__7\ninputargs: v2730\n\nreturn v2730", color="black", fillcolor="green", style="filled", width="0.75"];
+_generated____6 [shape="box", label="generated__6\ninputargs: v2732 v2733\n\nv2731 = same_as((17))\l", color="black", fillcolor="white", style="filled", width="0.75"];
+edge [label="v2731 v2732", style="solid", color="black", dir="forward", weight="5"];
+_generated____6 -> _generated____5
+_generated____4 [shape="box", label="generated__4\ninputargs: v2734\n\nv2735 = int_sub(v2734, (1))\lv2736 = int_add((55), v2735)\l", color="black", fillcolor="white", style="filled", width="0.75"];
+edge [label="v2736", style="solid", color="black", dir="forward", weight="5"];
+_generated____4 -> _generated____7
+}
+}
+'''
+
+def setup_module(mod):
+    if not option.pygame:
+        py.test.skip("--pygame not enabled")
+    udir = py.path.local.make_numbered_dir(prefix='usession-dot-', keep=3)
+    udir.join('graph1.dot').write(SOURCE1)
+
+    from dotviewer import graphclient
+    mod.pkgdir = py.path.local(graphclient.this_dir)
+    mod.udir = udir
+
+    try:
+        del os.environ['GRAPHSERVER']
+    except KeyError:
+        pass
+
+
+def test_dotviewer():
+    print "=== dotviewer.py graph1.dot"
+    err = os.system('"%s" "%s"' % (pkgdir.join('dotviewer.py'),
+                                   udir.join('graph1.dot')))
+    assert err == 0
+    os.system('dot -Tplain "%s" > "%s"' % (udir.join('graph1.dot'),
+                                           udir.join('