PageRenderTime 27ms CodeModel.GetById 1ms RepoModel.GetById 0ms app.codeStats 0ms

/dotviewer/drawgraph.py

https://bitbucket.org/kkris/pypy
Python | 683 lines | 621 code | 49 blank | 13 comment | 59 complexity | 441c3974e5fed5056b33d46ad904c1ba MD5 | raw file
  1. """
  2. A custom graphic renderer for the '.plain' files produced by dot.
  3. """
  4. from __future__ import generators
  5. import re, os, math
  6. import pygame
  7. from pygame.locals import *
  8. this_dir = os.path.dirname(os.path.abspath(__file__))
  9. FONT = os.path.join(this_dir, 'cyrvetic.ttf')
  10. FIXEDFONT = os.path.join(this_dir, 'VeraMoBd.ttf')
  11. COLOR = {
  12. 'black': (0,0,0),
  13. 'white': (255,255,255),
  14. 'red': (255,0,0),
  15. 'green': (0,255,0),
  16. 'blue': (0,0,255),
  17. 'yellow': (255,255,0),
  18. }
  19. re_nonword=re.compile(r'([^0-9a-zA-Z_.]+)')
  20. def combine(color1, color2, alpha):
  21. r1, g1, b1 = color1
  22. r2, g2, b2 = color2
  23. beta = 1.0 - alpha
  24. return (int(r1 * alpha + r2 * beta),
  25. int(g1 * alpha + g2 * beta),
  26. int(b1 * alpha + b2 * beta))
  27. def highlight_color(color):
  28. if color == (0, 0, 0): # black becomes magenta
  29. return (255, 0, 255)
  30. elif color == (255, 255, 255): # white becomes yellow
  31. return (255, 255, 0)
  32. intensity = sum(color)
  33. if intensity > 191 * 3:
  34. return combine(color, (128, 192, 0), 0.2)
  35. else:
  36. return combine(color, (255, 255, 0), 0.2)
  37. def getcolor(name, default):
  38. if name in COLOR:
  39. return COLOR[name]
  40. elif name.startswith('#') and len(name) == 7:
  41. rval = COLOR[name] = (int(name[1:3],16), int(name[3:5],16), int(name[5:7],16))
  42. return rval
  43. else:
  44. return default
  45. class GraphLayout:
  46. fixedfont = False
  47. def __init__(self, scale, width, height):
  48. self.scale = scale
  49. self.boundingbox = width, height
  50. self.nodes = {}
  51. self.edges = []
  52. self.links = {}
  53. def add_node(self, *args):
  54. n = Node(*args)
  55. self.nodes[n.name] = n
  56. def add_edge(self, *args):
  57. self.edges.append(Edge(self.nodes, *args))
  58. def get_display(self):
  59. from graphdisplay import GraphDisplay
  60. return GraphDisplay(self)
  61. def display(self):
  62. self.get_display().run()
  63. def reload(self):
  64. return self
  65. # async interaction helpers
  66. def display_async_quit():
  67. pygame.event.post(pygame.event.Event(QUIT))
  68. def display_async_cmd(**kwds):
  69. pygame.event.post(pygame.event.Event(USEREVENT, **kwds))
  70. EventQueue = []
  71. def wait_for_events():
  72. if not EventQueue:
  73. EventQueue.append(pygame.event.wait())
  74. EventQueue.extend(pygame.event.get())
  75. def wait_for_async_cmd():
  76. # wait until another thread pushes a USEREVENT in the queue
  77. while True:
  78. wait_for_events()
  79. e = EventQueue.pop(0)
  80. if e.type in (USEREVENT, QUIT): # discard all other events
  81. break
  82. EventQueue.insert(0, e) # re-insert the event for further processing
  83. class Node:
  84. def __init__(self, name, x, y, w, h, label, style, shape, color, fillcolor):
  85. self.name = name
  86. self.x = float(x)
  87. self.y = float(y)
  88. self.w = float(w)
  89. self.h = float(h)
  90. self.label = label
  91. self.style = style
  92. self.shape = shape
  93. self.color = color
  94. self.fillcolor = fillcolor
  95. self.highlight = False
  96. def sethighlight(self, which):
  97. self.highlight = bool(which)
  98. class Edge:
  99. label = None
  100. def __init__(self, nodes, tail, head, cnt, *rest):
  101. self.tail = nodes[tail]
  102. self.head = nodes[head]
  103. cnt = int(cnt)
  104. self.points = [(float(rest[i]), float(rest[i+1]))
  105. for i in range(0, cnt*2, 2)]
  106. rest = rest[cnt*2:]
  107. if len(rest) > 2:
  108. self.label, xl, yl = rest[:3]
  109. self.xl = float(xl)
  110. self.yl = float(yl)
  111. rest = rest[3:]
  112. self.style, self.color = rest
  113. self.highlight = False
  114. self.cachedbezierpoints = None
  115. self.cachedarrowhead = None
  116. self.cachedlimits = None
  117. def sethighlight(self, which):
  118. self.highlight = bool(which)
  119. def limits(self):
  120. result = self.cachedlimits
  121. if result is None:
  122. points = self.bezierpoints()
  123. xs = [point[0] for point in points]
  124. ys = [point[1] for point in points]
  125. self.cachedlimits = result = (min(xs), max(ys), max(xs), min(ys))
  126. return result
  127. def bezierpoints(self):
  128. result = self.cachedbezierpoints
  129. if result is None:
  130. result = []
  131. pts = self.points
  132. for i in range(0, len(pts)-3, 3):
  133. result += beziercurve(pts[i], pts[i+1], pts[i+2], pts[i+3])
  134. self.cachedbezierpoints = result
  135. return result
  136. def arrowhead(self):
  137. result = self.cachedarrowhead
  138. if result is None:
  139. # we don't know if the list of points is in the right order
  140. # or not :-( try to guess...
  141. def dist(node, pt):
  142. return abs(node.x - pt[0]) + abs(node.y - pt[1])
  143. error_if_direct = (dist(self.head, self.points[-1]) +
  144. dist(self.tail, self.points[0]))
  145. error_if_reversed = (dist(self.tail, self.points[-1]) +
  146. dist(self.head, self.points[0]))
  147. if error_if_direct > error_if_reversed: # reversed edge
  148. head = 0
  149. dir = 1
  150. else:
  151. head = -1
  152. dir = -1
  153. n = 1
  154. while True:
  155. try:
  156. x0, y0 = self.points[head]
  157. x1, y1 = self.points[head+n*dir]
  158. except IndexError:
  159. result = []
  160. break
  161. vx = x0-x1
  162. vy = y0-y1
  163. try:
  164. f = 0.12 / math.sqrt(vx*vx + vy*vy)
  165. vx *= f
  166. vy *= f
  167. result = [(x0 + 0.9*vx, y0 + 0.9*vy),
  168. (x0 + 0.4*vy, y0 - 0.4*vx),
  169. (x0 - 0.4*vy, y0 + 0.4*vx)]
  170. break
  171. except (ZeroDivisionError, ValueError):
  172. n += 1
  173. self.cachedarrowhead = result
  174. return result
  175. def beziercurve((x0,y0), (x1,y1), (x2,y2), (x3,y3), resolution=8):
  176. result = []
  177. f = 1.0/(resolution-1)
  178. append = result.append
  179. for i in range(resolution):
  180. t = f*i
  181. t0 = (1-t)*(1-t)*(1-t)
  182. t1 = t *(1-t)*(1-t) * 3.0
  183. t2 = t * t *(1-t) * 3.0
  184. t3 = t * t * t
  185. append((x0*t0 + x1*t1 + x2*t2 + x3*t3,
  186. y0*t0 + y1*t1 + y2*t2 + y3*t3))
  187. return result
  188. def segmentdistance((x0,y0), (x1,y1), (x,y)):
  189. "Distance between the point (x,y) and the segment (x0,y0)-(x1,y1)."
  190. vx = x1-x0
  191. vy = y1-y0
  192. try:
  193. l = math.hypot(vx, vy)
  194. vx /= l
  195. vy /= l
  196. dlong = vx*(x-x0) + vy*(y-y0)
  197. except (ZeroDivisionError, ValueError):
  198. dlong = -1
  199. if dlong < 0.0:
  200. return math.hypot(x-x0, y-y0)
  201. elif dlong > l:
  202. return math.hypot(x-x1, y-y1)
  203. else:
  204. return abs(vy*(x-x0) - vx*(y-y0))
  205. class GraphRenderer:
  206. MARGIN = 0.6
  207. SCALEMIN = 3
  208. SCALEMAX = 100
  209. FONTCACHE = {}
  210. def __init__(self, screen, graphlayout, scale=75):
  211. self.graphlayout = graphlayout
  212. self.setscale(scale)
  213. self.setoffset(0, 0)
  214. self.screen = screen
  215. self.textzones = []
  216. self.highlightwords = graphlayout.links
  217. self.highlight_word = None
  218. self.visiblenodes = []
  219. self.visibleedges = []
  220. def wordcolor(self, word):
  221. info = self.highlightwords[word]
  222. if isinstance(info, tuple) and len(info) >= 2:
  223. color = info[1]
  224. else:
  225. color = None
  226. if color is None:
  227. color = (128,0,0)
  228. if word == self.highlight_word:
  229. return ((255,255,80), color)
  230. else:
  231. return (color, None)
  232. def setscale(self, scale):
  233. scale = max(min(scale, self.SCALEMAX), self.SCALEMIN)
  234. self.scale = float(scale)
  235. w, h = self.graphlayout.boundingbox
  236. self.margin = int(self.MARGIN * scale)
  237. self.width = int(w * scale) + (2 * self.margin)
  238. self.height = int(h * scale) + (2 * self.margin)
  239. self.bboxh = h
  240. size = int(15 * (scale-10) / 75)
  241. self.font = self.getfont(size)
  242. def getfont(self, size):
  243. if size in self.FONTCACHE:
  244. return self.FONTCACHE[size]
  245. elif size < 5:
  246. self.FONTCACHE[size] = None
  247. return None
  248. else:
  249. if self.graphlayout.fixedfont:
  250. filename = FIXEDFONT
  251. else:
  252. filename = FONT
  253. font = self.FONTCACHE[size] = pygame.font.Font(filename, size)
  254. return font
  255. def setoffset(self, offsetx, offsety):
  256. "Set the (x,y) origin of the rectangle where the graph will be rendered."
  257. self.ofsx = offsetx - self.margin
  258. self.ofsy = offsety - self.margin
  259. def shiftoffset(self, dx, dy):
  260. self.ofsx += dx
  261. self.ofsy += dy
  262. def getcenter(self):
  263. w, h = self.screen.get_size()
  264. return self.revmap(w//2, h//2)
  265. def setcenter(self, x, y):
  266. w, h = self.screen.get_size()
  267. x, y = self.map(x, y)
  268. self.shiftoffset(x-w//2, y-h//2)
  269. def shiftscale(self, factor, fix=None):
  270. if fix is None:
  271. fixx, fixy = self.screen.get_size()
  272. fixx //= 2
  273. fixy //= 2
  274. else:
  275. fixx, fixy = fix
  276. x, y = self.revmap(fixx, fixy)
  277. self.setscale(self.scale * factor)
  278. newx, newy = self.map(x, y)
  279. self.shiftoffset(newx - fixx, newy - fixy)
  280. def reoffset(self, swidth, sheight):
  281. offsetx = noffsetx = self.ofsx
  282. offsety = noffsety = self.ofsy
  283. width = self.width
  284. height = self.height
  285. # if it fits, center it, otherwise clamp
  286. if width <= swidth:
  287. noffsetx = (width - swidth) // 2
  288. else:
  289. noffsetx = min(max(0, offsetx), width - swidth)
  290. if height <= sheight:
  291. noffsety = (height - sheight) // 2
  292. else:
  293. noffsety = min(max(0, offsety), height - sheight)
  294. self.ofsx = noffsetx
  295. self.ofsy = noffsety
  296. def getboundingbox(self):
  297. "Get the rectangle where the graph will be rendered."
  298. return (-self.ofsx, -self.ofsy, self.width, self.height)
  299. def visible(self, x1, y1, x2, y2):
  300. """Is any part of the box visible (i.e. within the bounding box)?
  301. We have to perform clipping ourselves because with big graphs the
  302. coordinates may sometimes become longs and cause OverflowErrors
  303. within pygame.
  304. """
  305. w, h = self.screen.get_size()
  306. return x1 < w and x2 > 0 and y1 < h and y2 > 0
  307. def computevisible(self):
  308. del self.visiblenodes[:]
  309. del self.visibleedges[:]
  310. w, h = self.screen.get_size()
  311. for node in self.graphlayout.nodes.values():
  312. x, y = self.map(node.x, node.y)
  313. nw2 = int(node.w * self.scale)//2
  314. nh2 = int(node.h * self.scale)//2
  315. if x-nw2 < w and x+nw2 > 0 and y-nh2 < h and y+nh2 > 0:
  316. self.visiblenodes.append(node)
  317. for edge in self.graphlayout.edges:
  318. x1, y1, x2, y2 = edge.limits()
  319. x1, y1 = self.map(x1, y1)
  320. if x1 < w and y1 < h:
  321. x2, y2 = self.map(x2, y2)
  322. if x2 > 0 and y2 > 0:
  323. self.visibleedges.append(edge)
  324. def map(self, x, y):
  325. return (int(x*self.scale) - (self.ofsx - self.margin),
  326. int((self.bboxh-y)*self.scale) - (self.ofsy - self.margin))
  327. def revmap(self, px, py):
  328. return ((px + (self.ofsx - self.margin)) / self.scale,
  329. self.bboxh - (py + (self.ofsy - self.margin)) / self.scale)
  330. def draw_node_commands(self, node):
  331. xcenter, ycenter = self.map(node.x, node.y)
  332. boxwidth = int(node.w * self.scale)
  333. boxheight = int(node.h * self.scale)
  334. fgcolor = getcolor(node.color, (0,0,0))
  335. bgcolor = getcolor(node.fillcolor, (255,255,255))
  336. if node.highlight:
  337. fgcolor = highlight_color(fgcolor)
  338. bgcolor = highlight_color(bgcolor)
  339. text = node.label
  340. lines = text.replace('\\l','\\l\n').replace('\r','\r\n').split('\n')
  341. # ignore a final newline
  342. if not lines[-1]:
  343. del lines[-1]
  344. wmax = 0
  345. hmax = 0
  346. commands = []
  347. bkgndcommands = []
  348. if self.font is None:
  349. if lines:
  350. raw_line = lines[0].replace('\\l','').replace('\r','')
  351. if raw_line:
  352. for size in (12, 10, 8, 6, 4):
  353. font = self.getfont(size)
  354. img = TextSnippet(self, raw_line, (0, 0, 0), bgcolor, font=font)
  355. w, h = img.get_size()
  356. if (w >= boxwidth or h >= boxheight):
  357. continue
  358. else:
  359. if w>wmax: wmax = w
  360. def cmd(img=img, y=hmax, w=w):
  361. img.draw(xcenter-w//2, ytop+y)
  362. commands.append(cmd)
  363. hmax += h
  364. break
  365. else:
  366. for line in lines:
  367. raw_line = line.replace('\\l','').replace('\r','') or ' '
  368. if '\f' in raw_line: # grayed out parts of the line
  369. imgs = []
  370. graytext = True
  371. h = 16
  372. w_total = 0
  373. for linepart in raw_line.split('\f'):
  374. graytext = not graytext
  375. if not linepart.strip():
  376. continue
  377. if graytext:
  378. fgcolor = (128, 160, 160)
  379. else:
  380. fgcolor = (0, 0, 0)
  381. img = TextSnippet(self, linepart, fgcolor, bgcolor)
  382. imgs.append((w_total, img))
  383. w, h = img.get_size()
  384. w_total += w
  385. if w_total > wmax: wmax = w_total
  386. def cmd(imgs=imgs, y=hmax):
  387. for x, img in imgs:
  388. img.draw(xleft+x, ytop+y)
  389. commands.append(cmd)
  390. else:
  391. img = TextSnippet(self, raw_line, (0, 0, 0), bgcolor)
  392. w, h = img.get_size()
  393. if w>wmax: wmax = w
  394. if raw_line.strip():
  395. if line.endswith('\\l'):
  396. def cmd(img=img, y=hmax):
  397. img.draw(xleft, ytop+y)
  398. elif line.endswith('\r'):
  399. def cmd(img=img, y=hmax, w=w):
  400. img.draw(xright-w, ytop+y)
  401. else:
  402. def cmd(img=img, y=hmax, w=w):
  403. img.draw(xcenter-w//2, ytop+y)
  404. commands.append(cmd)
  405. hmax += h
  406. #hmax += 8
  407. # we know the bounding box only now; setting these variables will
  408. # have an effect on the values seen inside the cmd() functions above
  409. xleft = xcenter - wmax//2
  410. xright = xcenter + wmax//2
  411. ytop = ycenter - hmax//2
  412. x = xcenter-boxwidth//2
  413. y = ycenter-boxheight//2
  414. if node.shape == 'box':
  415. rect = (x-1, y-1, boxwidth+2, boxheight+2)
  416. def cmd():
  417. self.screen.fill(bgcolor, rect)
  418. bkgndcommands.append(cmd)
  419. def cmd():
  420. pygame.draw.rect(self.screen, fgcolor, rect, 1)
  421. commands.append(cmd)
  422. elif node.shape == 'ellipse':
  423. rect = (x-1, y-1, boxwidth+2, boxheight+2)
  424. def cmd():
  425. pygame.draw.ellipse(self.screen, bgcolor, rect, 0)
  426. bkgndcommands.append(cmd)
  427. def cmd():
  428. pygame.draw.ellipse(self.screen, fgcolor, rect, 1)
  429. commands.append(cmd)
  430. elif node.shape == 'octagon':
  431. step = 1-math.sqrt(2)/2
  432. points = [(int(x+boxwidth*fx), int(y+boxheight*fy))
  433. for fx, fy in [(step,0), (1-step,0),
  434. (1,step), (1,1-step),
  435. (1-step,1), (step,1),
  436. (0,1-step), (0,step)]]
  437. def cmd():
  438. pygame.draw.polygon(self.screen, bgcolor, points, 0)
  439. bkgndcommands.append(cmd)
  440. def cmd():
  441. pygame.draw.polygon(self.screen, fgcolor, points, 1)
  442. commands.append(cmd)
  443. return bkgndcommands, commands
  444. def draw_commands(self):
  445. nodebkgndcmd = []
  446. nodecmd = []
  447. for node in self.visiblenodes:
  448. cmd1, cmd2 = self.draw_node_commands(node)
  449. nodebkgndcmd += cmd1
  450. nodecmd += cmd2
  451. edgebodycmd = []
  452. edgeheadcmd = []
  453. for edge in self.visibleedges:
  454. fgcolor = getcolor(edge.color, (0,0,0))
  455. if edge.highlight:
  456. fgcolor = highlight_color(fgcolor)
  457. points = [self.map(*xy) for xy in edge.bezierpoints()]
  458. def drawedgebody(points=points, fgcolor=fgcolor):
  459. pygame.draw.lines(self.screen, fgcolor, False, points)
  460. edgebodycmd.append(drawedgebody)
  461. points = [self.map(*xy) for xy in edge.arrowhead()]
  462. if points:
  463. def drawedgehead(points=points, fgcolor=fgcolor):
  464. pygame.draw.polygon(self.screen, fgcolor, points, 0)
  465. edgeheadcmd.append(drawedgehead)
  466. if edge.label:
  467. x, y = self.map(edge.xl, edge.yl)
  468. img = TextSnippet(self, edge.label, (0, 0, 0))
  469. w, h = img.get_size()
  470. if self.visible(x-w//2, y-h//2, x+w//2, y+h//2):
  471. def drawedgelabel(img=img, x1=x-w//2, y1=y-h//2):
  472. img.draw(x1, y1)
  473. edgeheadcmd.append(drawedgelabel)
  474. return edgebodycmd + nodebkgndcmd + edgeheadcmd + nodecmd
  475. def render(self):
  476. self.computevisible()
  477. bbox = self.getboundingbox()
  478. ox, oy, width, height = bbox
  479. dpy_width, dpy_height = self.screen.get_size()
  480. # some versions of the SDL misinterpret widely out-of-range values,
  481. # so clamp them
  482. if ox < 0:
  483. width += ox
  484. ox = 0
  485. if oy < 0:
  486. height += oy
  487. oy = 0
  488. if width > dpy_width:
  489. width = dpy_width
  490. if height > dpy_height:
  491. height = dpy_height
  492. self.screen.fill((224, 255, 224), (ox, oy, width, height))
  493. # gray off-bkgnd areas
  494. gray = (128, 128, 128)
  495. if ox > 0:
  496. self.screen.fill(gray, (0, 0, ox, dpy_height))
  497. if oy > 0:
  498. self.screen.fill(gray, (0, 0, dpy_width, oy))
  499. w = dpy_width - (ox + width)
  500. if w > 0:
  501. self.screen.fill(gray, (dpy_width-w, 0, w, dpy_height))
  502. h = dpy_height - (oy + height)
  503. if h > 0:
  504. self.screen.fill(gray, (0, dpy_height-h, dpy_width, h))
  505. # draw the graph and record the position of texts
  506. del self.textzones[:]
  507. for cmd in self.draw_commands():
  508. cmd()
  509. def findall(self, searchstr):
  510. """Return an iterator for all nodes and edges that contain a searchstr.
  511. """
  512. for item in self.graphlayout.nodes.itervalues():
  513. if item.label and searchstr in item.label:
  514. yield item
  515. for item in self.graphlayout.edges:
  516. if item.label and searchstr in item.label:
  517. yield item
  518. def at_position(self, (x, y)):
  519. """Figure out the word under the cursor."""
  520. for rx, ry, rw, rh, word in self.textzones:
  521. if rx <= x < rx+rw and ry <= y < ry+rh:
  522. return word
  523. return None
  524. def node_at_position(self, (x, y)):
  525. """Return the Node under the cursor."""
  526. x, y = self.revmap(x, y)
  527. for node in self.visiblenodes:
  528. if 2.0*abs(x-node.x) <= node.w and 2.0*abs(y-node.y) <= node.h:
  529. return node
  530. return None
  531. def edge_at_position(self, (x, y), distmax=14):
  532. """Return the Edge near the cursor."""
  533. # XXX this function is very CPU-intensive and makes the display kinda sluggish
  534. distmax /= self.scale
  535. xy = self.revmap(x, y)
  536. closest_edge = None
  537. for edge in self.visibleedges:
  538. pts = edge.bezierpoints()
  539. for i in range(1, len(pts)):
  540. d = segmentdistance(pts[i-1], pts[i], xy)
  541. if d < distmax:
  542. distmax = d
  543. closest_edge = edge
  544. return closest_edge
  545. class TextSnippet:
  546. def __init__(self, renderer, text, fgcolor, bgcolor=None, font=None):
  547. self.renderer = renderer
  548. self.imgs = []
  549. self.parts = []
  550. if font is None:
  551. font = renderer.font
  552. if font is None:
  553. return
  554. parts = self.parts
  555. for word in re_nonword.split(text):
  556. if not word:
  557. continue
  558. if word in renderer.highlightwords:
  559. fg, bg = renderer.wordcolor(word)
  560. bg = bg or bgcolor
  561. else:
  562. fg, bg = fgcolor, bgcolor
  563. parts.append((word, fg, bg))
  564. # consolidate sequences of words with the same color
  565. for i in range(len(parts)-2, -1, -1):
  566. if parts[i][1:] == parts[i+1][1:]:
  567. word, fg, bg = parts[i]
  568. parts[i] = word + parts[i+1][0], fg, bg
  569. del parts[i+1]
  570. # delete None backgrounds
  571. for i in range(len(parts)):
  572. if parts[i][2] is None:
  573. parts[i] = parts[i][:2]
  574. # render parts
  575. i = 0
  576. while i < len(parts):
  577. part = parts[i]
  578. word = part[0]
  579. try:
  580. try:
  581. img = font.render(word, False, *part[1:])
  582. except pygame.error, e:
  583. # Try *with* anti-aliasing to work around a bug in SDL
  584. img = font.render(word, True, *part[1:])
  585. except pygame.error:
  586. del parts[i] # Text has zero width
  587. else:
  588. self.imgs.append(img)
  589. i += 1
  590. def get_size(self):
  591. if self.imgs:
  592. sizes = [img.get_size() for img in self.imgs]
  593. return sum([w for w,h in sizes]), max([h for w,h in sizes])
  594. else:
  595. return 0, 0
  596. def draw(self, x, y):
  597. for part, img in zip(self.parts, self.imgs):
  598. word = part[0]
  599. self.renderer.screen.blit(img, (x, y))
  600. w, h = img.get_size()
  601. self.renderer.textzones.append((x, y, w, h, word))
  602. x += w