PageRenderTime 51ms CodeModel.GetById 21ms RepoModel.GetById 1ms app.codeStats 0ms

/dotviewer/drawgraph.py

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