aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArmin Rigo <arigo@tunes.org>2007-06-16 17:33:06 +0000
committerArmin Rigo <arigo@tunes.org>2007-06-16 17:33:06 +0000
commit603cee821908be4b91ac22bcf47e6186db28de03 (patch)
treecc9ad633f8fdf5a1531656529e57e6b36c054e2a /dotviewer/graphdisplay.py
parentsanitize a bit the ExternalType mess: (diff)
downloadpypy-603cee821908be4b91ac22bcf47e6186db28de03.tar.gz
pypy-603cee821908be4b91ac22bcf47e6186db28de03.tar.bz2
pypy-603cee821908be4b91ac22bcf47e6186db28de03.zip
Merge the graphserver-dist branch.
For description, see my mail "Pygame viewer" on pypy-dev.
Diffstat (limited to 'dotviewer/graphdisplay.py')
-rw-r--r--dotviewer/graphdisplay.py758
1 files changed, 758 insertions, 0 deletions
diff --git a/dotviewer/graphdisplay.py b/dotviewer/graphdisplay.py
new file mode 100644
index 0000000000..582ab40c55
--- /dev/null
+++ b/dotviewer/graphdisplay.py
@@ -0,0 +1,758 @@
+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