passagewidget.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. import copy, math, colorsys, re, wx, tiddlywiki, tweelexer
  2. import geometry, metrics, images
  3. from passageframe import PassageFrame, ImageFrame, StorySettingsFrame
  4. class PassageWidget(object):
  5. """
  6. A PassageWidget is a box standing in for a proxy for a single
  7. passage in a story. Users can drag them around, double-click
  8. to open a PassageFrame, and so on.
  9. This must have a StoryPanel as its parent.
  10. See the comments on StoryPanel for more information on the
  11. coordinate systems are used here. In general, you should
  12. always pass methods logical coordinates, and expect back
  13. logical coordinates. Use StoryPanel.toPixels() to convert.
  14. """
  15. def __init__(self, parent, app, pos = (0, 0), title = '', text = '', tags = (), state = None):
  16. # inner state
  17. self.parent = parent
  18. self.app = app
  19. self.dimmed = False
  20. self.brokenEmblem = wx.Bitmap(self.app.iconsPath + 'brokenemblem.png')
  21. self.externalEmblem = wx.Bitmap(self.app.iconsPath + 'externalemblem.png')
  22. self.paintBuffer = wx.MemoryDC()
  23. self.paintBufferBounds = None
  24. if state:
  25. self.passage = state['passage']
  26. self.pos = list(pos) if pos != (0,0) else state['pos']
  27. self.selected = state['selected']
  28. else:
  29. self.passage = tiddlywiki.Tiddler('')
  30. self.selected = False
  31. self.pos = list(pos)
  32. if title: self.passage.title = title
  33. if text: self.passage.text = text
  34. if tags: self.passage.tags += tags
  35. self.bitmap = None
  36. self.updateBitmap()
  37. self.passage.update()
  38. def getSize(self):
  39. """Returns this instance's logical size."""
  40. if self.passage.isAnnotation():
  41. return (PassageWidget.SIZE+self.parent.GRID_SPACING, PassageWidget.SIZE+self.parent.GRID_SPACING)
  42. return (PassageWidget.SIZE, PassageWidget.SIZE)
  43. def getCenter(self):
  44. """Returns this instance's center in logical coordinates."""
  45. pos = list(self.pos)
  46. pos[0] += self.getSize()[0] / 2
  47. pos[1] += self.getSize()[1] / 2
  48. return pos
  49. def getLogicalRect(self):
  50. """Returns this instance's rectangle in logical coordinates."""
  51. size = self.getSize()
  52. return wx.Rect(self.pos[0], self.pos[1], size[0], size[1])
  53. def getPixelRect(self):
  54. """Returns this instance's rectangle onscreen."""
  55. origin = self.parent.toPixels(self.pos)
  56. size = self.parent.toPixels(self.getSize(), scaleOnly = True)
  57. return wx.Rect(origin[0], origin[1], size[0], size[1])
  58. def getDirtyPixelRect(self):
  59. """
  60. Returns a pixel rectangle of everything that needs to be redrawn for the widget
  61. in its current position. This includes the widget itself as well as any
  62. other widgets it links to.
  63. """
  64. dirtyRect = self.getPixelRect()
  65. # first, passages we link to
  66. for link in self.passage.links:
  67. widget = self.parent.findWidget(link)
  68. if widget:
  69. dirtyRect.Union(widget.getPixelRect())
  70. # then, those that link to us
  71. def addLinkingToRect(widget):
  72. if self.passage.title in widget.passage.links:
  73. dirtyRect.Union(widget.getPixelRect())
  74. self.parent.eachWidget(addLinkingToRect)
  75. return dirtyRect
  76. def offset(self, x = 0, y = 0):
  77. """Offsets this widget's position by logical coordinates."""
  78. self.pos = list(self.pos)
  79. self.pos[0] += x
  80. self.pos[1] += y
  81. def findSpace(self):
  82. """Moves this widget so it doesn't overlap any others."""
  83. turns = 0.0
  84. movecount = 1
  85. """
  86. Don't adhere to the grid if snapping isn't enabled.
  87. Instead, move in 1/5 grid increments.
  88. """
  89. griddivision = 1 if self.parent.snapping else 0.2
  90. while self.intersectsAny() and turns < 99*griddivision:
  91. """Move in an Ulam spiral pattern: n spaces left, n spaces up, n+1 spaces right, n+1 spaces down"""
  92. self.pos[int(math.floor((turns*2) % 2))] += self.parent.GRID_SPACING * griddivision * int(math.copysign(1, turns % 2 - 1))
  93. movecount -= 1
  94. if movecount <= 0:
  95. turns += 0.5
  96. movecount = int(math.ceil(turns)/griddivision)
  97. def findSpaceQuickly(self):
  98. """ A quicker findSpace where the position and visibility doesn't really matter """
  99. while self.intersectsAny():
  100. self.pos[0] += self.parent.GRID_SPACING
  101. rightEdge = self.pos[0] + PassageWidget.SIZE
  102. maxWidth = self.parent.toLogical((self.parent.GetSize().width - self.parent.INSET[0], -1), \
  103. scaleOnly = True)[0]
  104. if rightEdge > maxWidth:
  105. self.pos[0] = 10
  106. self.pos[1] += self.parent.GRID_SPACING
  107. def containsRegexp(self, regexp, flags):
  108. """
  109. Returns whether this widget's passage contains a regexp.
  110. """
  111. return (re.search(regexp, self.passage.title, flags) is not None
  112. or re.search(regexp, self.passage.text, flags) is not None)
  113. def replaceRegexp(self, findRegexp, replaceRegexp, flags):
  114. """
  115. Performs a regexp replace in this widget's passage title and
  116. body text. Returns the number of replacements actually made.
  117. """
  118. compiledRegexp = re.compile(findRegexp, flags)
  119. oldTitle = self.passage.title
  120. newTitle, titleReps = re.subn(compiledRegexp, replaceRegexp, oldTitle)
  121. self.passage.text, textReps = re.subn(compiledRegexp, replaceRegexp, self.passage.text)
  122. if titleReps > 0:
  123. self.parent.changeWidgetTitle(oldTitle, newTitle)
  124. return titleReps + textReps
  125. def linksAndDisplays(self):
  126. return self.passage.linksAndDisplays() + self.getShorthandDisplays()
  127. def getShorthandDisplays(self):
  128. """Returns a list of macro tags which match passage names."""
  129. return filter(self.parent.passageExists, self.passage.macros)
  130. def getBrokenLinks(self):
  131. """Returns a list of broken links in this widget."""
  132. return filter(lambda a: not self.parent.passageExists(a), self.passage.links)
  133. def getIncludedLinks(self):
  134. """Returns a list of included passages in this widget."""
  135. return filter(self.parent.includedPassageExists, self.passage.links)
  136. def getVariableLinks(self):
  137. """Returns a list of links which use variables/functions, in this widget."""
  138. return filter(lambda a: tweelexer.TweeLexer.linkStyle(a) == tweelexer.TweeLexer.PARAM, self.passage.links)
  139. def setSelected(self, value, exclusive = True):
  140. """
  141. Sets whether this widget should be selected. Pass a false value for
  142. exclusive to prevent other widgets from being deselected.
  143. """
  144. if exclusive:
  145. self.parent.eachWidget(lambda i: i.setSelected(False, False))
  146. old = self.selected
  147. self.selected = value
  148. if self.selected != old:
  149. self.clearPaintCache()
  150. # Figure out the dirty rect
  151. dirtyRect = self.getPixelRect()
  152. for link in self.linksAndDisplays() + self.passage.images:
  153. widget = self.parent.findWidget(link)
  154. if widget:
  155. dirtyRect.Union(widget.getDirtyPixelRect())
  156. if self.passage.isStylesheet():
  157. for t in self.passage.tags:
  158. if t not in tiddlywiki.TiddlyWiki.INFO_TAGS:
  159. for widget in self.parent.taggedWidgets(t):
  160. if widget:
  161. dirtyRect.Union(widget.getDirtyPixelRect())
  162. self.parent.Refresh(True, dirtyRect)
  163. def setDimmed(self, value):
  164. """Sets whether this widget should be dimmed."""
  165. old = self.dimmed
  166. self.dimmed = value
  167. if self.dimmed != old:
  168. self.clearPaintCache()
  169. def clearPaintCache(self):
  170. """
  171. Forces the widget to be repainted from scratch.
  172. """
  173. self.paintBufferBounds = None
  174. def openContextMenu(self, event):
  175. """Opens a contextual menu at the event position given."""
  176. self.parent.PopupMenu(PassageWidgetContext(self), event.GetPosition())
  177. def openEditor(self, event = None, fullscreen = False):
  178. """Opens a PassageFrame to edit this passage."""
  179. image = self.passage.isImage()
  180. if not hasattr(self, 'passageFrame'):
  181. if image:
  182. self.passageFrame = ImageFrame(None, self, self.app)
  183. elif self.passage.title == "StorySettings":
  184. self.passageFrame = StorySettingsFrame(None, self, self.app)
  185. else:
  186. self.passageFrame = PassageFrame(None, self, self.app)
  187. if fullscreen: self.passageFrame.openFullscreen()
  188. else:
  189. try:
  190. self.passageFrame.Iconize(False)
  191. self.passageFrame.Raise()
  192. if fullscreen and not image: self.passageFrame.openFullscreen()
  193. except wx._core.PyDeadObjectError:
  194. # user closed the frame, so we need to recreate it
  195. delattr(self, 'passageFrame')
  196. self.openEditor(event, fullscreen)
  197. def closeEditor(self, event = None):
  198. """Closes the PassageFrame associated with this, if it exists."""
  199. try: self.passageFrame.closeEditor()
  200. except: pass
  201. try: self.passageFrame.Destroy()
  202. except: pass
  203. def verifyPassage(self, window):
  204. """
  205. Check that the passage syntax is well-formed.
  206. Return -(corrections made) if the check was aborted, +(corrections made) otherwise
  207. """
  208. passage = self.passage
  209. checks = tweelexer.VerifyLexer(self).check()
  210. problems = 0
  211. oldtext = passage.text
  212. newtext = ""
  213. index = 0
  214. for warning, replace in checks:
  215. problems += 1
  216. if replace:
  217. start, sub, end = replace
  218. answer = wx.MessageDialog(window, warning + "\n\nMay I try to fix this for you?",
  219. 'Problem in ' + self.passage.title,
  220. wx.ICON_WARNING | wx.YES_NO | wx.CANCEL | wx.YES_DEFAULT
  221. ).ShowModal()
  222. if answer == wx.ID_YES:
  223. newtext += oldtext[index:start] + sub
  224. index = end
  225. if hasattr(self, 'passageFrame') and self.passageFrame:
  226. self.passageFrame.bodyInput.SetText(newtext + oldtext[index:])
  227. elif answer == wx.ID_CANCEL:
  228. return -problems
  229. else:
  230. answer = wx.MessageDialog(window, warning+"\n\nKeep checking?", 'Problem in '+self.passage.title, wx.ICON_WARNING | wx.YES_NO) \
  231. .ShowModal()
  232. if answer == wx.ID_NO:
  233. return problems
  234. passage.text = newtext + oldtext[index:]
  235. return problems
  236. def intersectsAny(self, dragging = False):
  237. """Returns whether this widget intersects any other in the same StoryPanel."""
  238. #Enforce positive coordinates
  239. if not 'Twine.hide' in self.passage.tags:
  240. if self.pos[0] < 0 or self.pos[1] < 0:
  241. return True
  242. # we do this manually so we don't have to go through all of them
  243. for widget in self.parent.notDraggingWidgets if dragging else self.parent.widgetDict.itervalues():
  244. if widget != self and self.intersects(widget):
  245. return True
  246. return False
  247. def intersects(self, other):
  248. """
  249. Returns whether this widget intersects another widget or wx.Rect.
  250. This uses logical coordinates, so you can do this without actually moving the widget onscreen.
  251. """
  252. selfRect = self.getLogicalRect()
  253. if isinstance(other, PassageWidget):
  254. other = other.getLogicalRect()
  255. return selfRect.Intersects(other)
  256. def applyPrefs(self):
  257. """Passes on the message to any editor windows."""
  258. self.clearPaintCache()
  259. try: self.passageFrame.applyPrefs()
  260. except: pass
  261. try: self.passageFrame.fullscreen.applyPrefs()
  262. except: pass
  263. def updateBitmap(self):
  264. """If an image passage, updates the bitmap to match the contained base64 data."""
  265. if self.passage.isImage():
  266. self.bitmap = images.base64ToBitmap(self.passage.text)
  267. def getConnectorLine(self, otherWidget, clipped=True):
  268. """
  269. Get the line that would be drawn between this widget and another.
  270. """
  271. start = self.getCenter()
  272. end = otherWidget.getCenter()
  273. #Tweak to make overlapping lines easier to see by shifting the end point
  274. #Devision by a large constant to so the behavior is not overly noticeable while dragging
  275. lengthSquared = ((start[0]-end[0])**2+(start[1]-end[1])**2)/1024**2
  276. end[0] += (0.5 - math.sin(lengthSquared))*PassageWidget.SIZE/8.0
  277. end[1] += (0.5 - math.cos(lengthSquared))*PassageWidget.SIZE/8.0
  278. if clipped:
  279. [start, end] = geometry.clipLineByRects([start, end], otherWidget.getLogicalRect())
  280. return self.parent.toPixels(start), self.parent.toPixels(end)
  281. def getConnectedWidgets(self, displayArrows, imageArrows):
  282. """
  283. Returns a list of titles of all widgets that will have lines drawn to them.
  284. """
  285. ret = []
  286. for link in self.linksAndDisplays():
  287. if link in self.passage.links or displayArrows:
  288. widget = self.parent.findWidget(link)
  289. if widget:
  290. ret.append(widget)
  291. if imageArrows:
  292. for link in self.passage.images:
  293. widget = self.parent.findWidget(link)
  294. if widget:
  295. ret.append(widget)
  296. if self.passage.isStylesheet():
  297. for t in self.passage.tags:
  298. if t not in tiddlywiki.TiddlyWiki.INFO_TAGS:
  299. for otherWidget in self.parent.taggedWidgets(t):
  300. if not otherWidget.dimmed and not otherWidget.passage.isStylesheet():
  301. ret.append(otherWidget)
  302. return ret
  303. def addConnectorLinesToDict(self, displayArrows, imageArrows, flatDesign, lineDictonary, arrowDictonary=None, updateRect=None):
  304. """
  305. Appended the connector lines originating from this widget to the list contained in the
  306. line directory under the appropriate color,width key.
  307. If an arrow dictionary is also passed it adds the arrows in a similar manner.
  308. If an update rect is passed it skips any lines, and the associated arrows,
  309. which lie outside the update rectangle.
  310. Note: Assumes the list existed in the passed in dictionaries. Either make sure this is the case or
  311. use a defaultDict.
  312. """
  313. colors = PassageWidget.FLAT_COLORS if flatDesign else PassageWidget.COLORS
  314. # Widths for selected and non selected lines
  315. widths = 2 * (2 * flatDesign + 1), 1 * (2 * flatDesign + 1)
  316. widths = max(self.parent.toPixels((widths[0], 0), scaleOnly=True)[0], 2), \
  317. max(self.parent.toPixels((widths[1], 0), scaleOnly=True)[0], 1)
  318. widgets = self.getConnectedWidgets(displayArrows, imageArrows)
  319. if widgets:
  320. for widget in widgets:
  321. link = widget.passage.title
  322. if self.passage.isAnnotation():
  323. color = colors['connectorAnnotation']
  324. elif (link in self.passage.displays + self.passage.macros) and link not in self.passage.links:
  325. color = colors['connectorDisplay']
  326. elif link in self.passage.images or self.passage.isStylesheet():
  327. color = colors['connectorResource']
  328. else:
  329. color = colors['connector']
  330. width = widths[not self.selected]
  331. line, arrow = self.getConnectorTo(widget, not arrowDictonary is None, updateRect)
  332. lineDictonary[(color, width)].extend(line)
  333. if arrow:
  334. arrowDictonary[(color, width)].extend(arrow)
  335. def getConnectorTo(self, otherWidget, arrowheads=False, updateRect=None):
  336. # does it actually need to be drawn?
  337. if otherWidget == self:
  338. return [], []
  339. start, end = self.getConnectorLine(otherWidget)
  340. if updateRect and not geometry.lineRectIntersection([start, end], updateRect):
  341. return [], []
  342. line = [[start[0], start[1]], [end[0], end[1]]]
  343. if not arrowheads:
  344. return line, []
  345. else:
  346. length = max(self.parent.toPixels((PassageWidget.ARROWHEAD_LENGTH, 0), scaleOnly=True)[0], 1)
  347. arrowheadr = geometry.endPointProjectedFrom((start, end), PassageWidget.ARROWHEAD_ANGLE, length)
  348. arrowheadl = geometry.endPointProjectedFrom((start, end), 0 - PassageWidget.ARROWHEAD_ANGLE, length)
  349. return line, [(arrowheadl, end, arrowheadr)]
  350. def paint(self, dc):
  351. """
  352. Handles paint events, either blitting our paint buffer or
  353. manually redrawing.
  354. """
  355. pixPos = self.parent.toPixels(self.pos)
  356. pixSize = self.parent.toPixels(self.getSize(), scaleOnly = True)
  357. rect = wx.Rect(pixPos[0], pixPos[1], pixSize[0], pixSize[1])
  358. if (not self.paintBufferBounds) \
  359. or (rect.width != self.paintBufferBounds.width \
  360. or rect.height != self.paintBufferBounds.height):
  361. self.cachePaint(wx.Size(rect.width, rect.height))
  362. dc.Blit(rect.x, rect.y, rect.width, rect.height, self.paintBuffer, 0, 0)
  363. def getTitleColor(self):
  364. """
  365. Returns the title bar style that matches this widget's passage.
  366. """
  367. flat = self.app.config.ReadBool('flatDesign')
  368. # First, rely on the header to supply colours
  369. custom = self.getHeader().passageTitleColor(self.passage)
  370. if custom:
  371. return custom[flat]
  372. # Use default colours
  373. if self.passage.isAnnotation():
  374. ind = 'annotation'
  375. elif self.passage.isImage():
  376. ind = 'imageTitleBar'
  377. elif any(t.startswith('Twine.') for t in self.passage.tags):
  378. ind = 'privateTitleBar'
  379. elif not self.linksAndDisplays() and not self.getIncludedLinks() and not self.passage.variableLinks:
  380. ind = 'endTitleBar'
  381. else:
  382. ind = 'titleBar'
  383. colors = PassageWidget.FLAT_COLORS if flat else PassageWidget.COLORS
  384. return colors[ind]
  385. def cachePaint(self, size):
  386. """
  387. Caches the widget so self.paintBuffer is up-to-date.
  388. """
  389. def wordWrap(text, lineWidth, gc):
  390. """
  391. Returns a list of lines from a string
  392. This is somewhat based on the wordwrap function built into wx.lib.
  393. (For some reason, GraphicsContext.GetPartialTextExtents()
  394. is returning totally wrong numbers but GetTextExtent() works fine.)
  395. This assumes that you've already set up the font you want on the GC.
  396. It gloms multiple spaces together, but for our purposes that's ok.
  397. """
  398. words = re.finditer('\S+\s*', text.replace('\r',''))
  399. lines = ''
  400. currentLine = ''
  401. for w in words:
  402. word = w.group(0)
  403. wordWidth = gc.GetTextExtent(currentLine + word)[0]
  404. if wordWidth < lineWidth:
  405. currentLine += word
  406. if '\n' in word:
  407. lines += currentLine
  408. currentLine = ''
  409. else:
  410. lines += currentLine + '\n'
  411. currentLine = word
  412. lines += currentLine
  413. return lines.split('\n')
  414. # Which set of colors to use
  415. flat = self.app.config.ReadBool('flatDesign')
  416. colors = PassageWidget.FLAT_COLORS if flat else PassageWidget.COLORS
  417. def dim(c, dim, flat=flat):
  418. """Lowers a color's alpha if dim is true."""
  419. if isinstance(c, wx.Colour):
  420. c = list(c.Get(includeAlpha=True))
  421. elif type(c) is str:
  422. c = list(ord(a) for a in c[1:].decode('hex'))
  423. else:
  424. c = list(c)
  425. if len(c) < 4:
  426. c.append(255)
  427. if dim:
  428. a = PassageWidget.FLAT_DIMMED_ALPHA if flat else PassageWidget.DIMMED_ALPHA
  429. if not self.app.config.ReadBool('fastStoryPanel'):
  430. c[3] *= a
  431. else:
  432. c[0] *= a
  433. c[1] *= a
  434. c[2] *= a
  435. return wx.Colour(*c)
  436. # set up our buffer
  437. bitmap = wx.EmptyBitmap(size.width, size.height)
  438. self.paintBuffer.SelectObject(bitmap)
  439. # switch to a GraphicsContext as necessary
  440. gc = self.paintBuffer if self.app.config.ReadBool('fastStoryPanel') else wx.GraphicsContext.Create(self.paintBuffer)
  441. # text font sizes
  442. # wxWindows works with points, so we need to doublecheck on actual pixels
  443. titleFontSize = self.parent.toPixels((metrics.size('widgetTitle'), -1), scaleOnly = True)[0]
  444. titleFontSize = sorted((metrics.size('fontMin'), titleFontSize, metrics.size('fontMax')))[1]
  445. excerptFontSize = sorted((metrics.size('fontMin'), titleFontSize * 0.9, metrics.size('fontMax')))[1]
  446. if self.app.config.ReadBool('flatDesign'):
  447. titleFont = wx.Font(titleFontSize, wx.SWISS, wx.NORMAL, wx.LIGHT, False, 'Arial')
  448. excerptFont = wx.Font(excerptFontSize, wx.SWISS, wx.NORMAL, wx.LIGHT, False, 'Arial')
  449. else:
  450. titleFont = wx.Font(titleFontSize, wx.SWISS, wx.NORMAL, wx.BOLD, False, 'Arial')
  451. excerptFont = wx.Font(excerptFontSize, wx.SWISS, wx.NORMAL, wx.NORMAL, False, 'Arial')
  452. titleFontHeight = math.fabs(titleFont.GetPixelSize()[1])
  453. excerptFontHeight = math.fabs(excerptFont.GetPixelSize()[1])
  454. tagBarColor = dim(tuple(i*256 for i in colorsys.hsv_to_rgb(0.14 + math.sin(hash("".join(self.passage.tags)))*0.08,
  455. 0.58 if flat else 0.28,
  456. 0.88)), self.dimmed)
  457. tags = set(self.passage.tags) - (tiddlywiki.TiddlyWiki.INFO_TAGS | self.getHeader().invisiblePassageTags())
  458. # inset for text (we need to know this for layout purposes)
  459. inset = titleFontHeight / 3
  460. # frame
  461. if self.passage.isAnnotation():
  462. frameColor = colors['frame']
  463. c = wx.Colour(*colors['annotation'])
  464. frameInterior = (c,c)
  465. else:
  466. frameColor = dim(colors['frame'], self.dimmed)
  467. frameInterior = (dim(colors['bodyStart'], self.dimmed), dim(colors['bodyEnd'], self.dimmed))
  468. if not flat:
  469. gc.SetPen(wx.Pen(frameColor, 1))
  470. if isinstance(gc, wx.GraphicsContext):
  471. gc.SetBrush(gc.CreateLinearGradientBrush(0, 0, 0, size.height, \
  472. frameInterior[0], frameInterior[1]))
  473. else:
  474. gc.GradientFillLinear(wx.Rect(0, 0, size.width - 1, size.height - 1), \
  475. frameInterior[0], frameInterior[1], wx.SOUTH)
  476. gc.SetBrush(wx.TRANSPARENT_BRUSH)
  477. gc.DrawRectangle(0, 0, size.width - 1, size.height - 1)
  478. else:
  479. gc.SetPen(wx.Pen(frameInterior[0]))
  480. gc.SetBrush(wx.Brush(frameInterior[0]))
  481. gc.DrawRectangle(0, 0, size.width, size.height)
  482. greek = size.width <= PassageWidget.MIN_GREEKING_SIZE * (2 if self.passage.isAnnotation() else 1)
  483. # title bar
  484. titleBarHeight = PassageWidget.GREEK_HEIGHT*3 if greek else titleFontHeight + (2 * inset)
  485. if self.passage.isAnnotation():
  486. titleBarColor = frameInterior[0]
  487. else:
  488. titleBarColor = dim(self.getTitleColor(), self.dimmed)
  489. gc.SetPen(wx.Pen(titleBarColor, 1))
  490. gc.SetBrush(wx.Brush(titleBarColor))
  491. if flat:
  492. gc.DrawRectangle(0, 0, size.width, titleBarHeight)
  493. else:
  494. gc.DrawRectangle(1, 1, size.width - 3, titleBarHeight)
  495. if not greek:
  496. # draw title
  497. # we let clipping prevent writing over the frame
  498. if isinstance(gc, wx.GraphicsContext):
  499. gc.ResetClip()
  500. gc.Clip(inset, inset, size.width - (inset * 2), titleBarHeight - 2)
  501. else:
  502. gc.DestroyClippingRegion()
  503. gc.SetClippingRect(wx.Rect(inset, inset, size.width - (inset * 2), titleBarHeight - 2))
  504. titleTextColor = dim(colors['titleText'], self.dimmed)
  505. if isinstance(gc, wx.GraphicsContext):
  506. gc.SetFont(titleFont, titleTextColor)
  507. else:
  508. gc.SetFont(titleFont)
  509. gc.SetTextForeground(titleTextColor)
  510. if self.passage.title:
  511. gc.DrawText(self.passage.title, inset, inset)
  512. # draw excerpt
  513. if not self.passage.isImage():
  514. excerptTop = inset + titleBarHeight
  515. # we split the excerpt by line, then draw them in turn
  516. # (we use a library to determine breaks, but have to draw the lines ourselves)
  517. if isinstance(gc, wx.GraphicsContext):
  518. gc.ResetClip()
  519. gc.Clip(inset, inset, size.width - (inset * 2), size.height - (inset * 2)-1)
  520. else:
  521. gc.DestroyClippingRegion()
  522. gc.SetClippingRect(wx.Rect(inset, inset, size.width - (inset * 2), size.height - (inset * 2)-1))
  523. if self.passage.isAnnotation():
  524. excerptTextColor = wx.Colour(*colors['annotationText'])
  525. else:
  526. excerptTextColor = dim(colors['excerptText'], self.dimmed)
  527. if isinstance(gc, wx.GraphicsContext):
  528. gc.SetFont(excerptFont, excerptTextColor)
  529. else:
  530. gc.SetFont(excerptFont)
  531. gc.SetTextForeground(excerptTextColor)
  532. excerptLines = wordWrap(self.passage.text, size.width - (inset * 2), gc)
  533. for line in excerptLines:
  534. gc.DrawText(line, inset, excerptTop)
  535. excerptTop += excerptFontHeight * PassageWidget.LINE_SPACING \
  536. * min(1.75,max(1,1.75*size.width/260 if (self.passage.isAnnotation() and line) else 1))
  537. if excerptTop + excerptFontHeight > size.height - inset: break
  538. if (self.passage.isStoryText() or self.passage.isStylesheet()) and tags:
  539. tagBarHeight = excerptFontHeight + (2 * inset)
  540. gc.SetPen(wx.Pen(tagBarColor, 1))
  541. gc.SetBrush(wx.Brush(tagBarColor))
  542. gc.DrawRectangle(0, size.height-tagBarHeight-1, size.width, tagBarHeight+1)
  543. # draw tags
  544. tagTextColor = dim(colors['frame'], self.dimmed)
  545. if isinstance(gc, wx.GraphicsContext):
  546. gc.SetFont(excerptFont, tagTextColor)
  547. else:
  548. gc.SetFont(excerptFont)
  549. gc.SetTextForeground(tagTextColor)
  550. text = wordWrap(' '.join(tags), size.width - (inset * 2), gc)[0]
  551. gc.DrawText(text, inset*2, (size.height-tagBarHeight))
  552. else:
  553. # greek title
  554. gc.SetPen(wx.Pen(colors['titleText'], PassageWidget.GREEK_HEIGHT))
  555. height = inset
  556. width = (size.width - inset) / 2
  557. if isinstance(gc, wx.GraphicsContext):
  558. gc.StrokeLine(inset, height, width, height)
  559. else:
  560. gc.DrawLine(inset, height, width, height)
  561. height += PassageWidget.GREEK_HEIGHT * 3
  562. # greek body text
  563. if not self.passage.isImage():
  564. gc.SetPen(wx.Pen(colors['annotationText'] \
  565. if self.passage.isAnnotation() else colors['greek'], PassageWidget.GREEK_HEIGHT))
  566. chars = len(self.passage.text)
  567. while height < size.height - inset and chars > 0:
  568. width = size.height - inset
  569. if height + (PassageWidget.GREEK_HEIGHT * 2) > size.height - inset:
  570. width /= 2
  571. elif chars < 80:
  572. width = max(4, width * chars / 80)
  573. if isinstance(gc, wx.GraphicsContext):
  574. gc.StrokeLine(inset, height, width, height)
  575. else:
  576. gc.DrawLine(inset, height, width, height)
  577. height += PassageWidget.GREEK_HEIGHT * 2
  578. chars -= 80
  579. # greek tags
  580. if (self.passage.isStoryText() or self.passage.isStylesheet()) and tags:
  581. tagBarHeight = PassageWidget.GREEK_HEIGHT*3
  582. gc.SetPen(wx.Pen(tagBarColor, 1))
  583. gc.SetBrush(wx.Brush(tagBarColor))
  584. height = size.height-tagBarHeight-2
  585. width = size.width-4
  586. gc.DrawRectangle(2, height, width, tagBarHeight)
  587. gc.SetPen(wx.Pen(colors['greek'], PassageWidget.GREEK_HEIGHT))
  588. height += inset
  589. width = (width-inset*2)/2
  590. if isinstance(gc, wx.GraphicsContext):
  591. gc.StrokeLine(inset, height, width, height)
  592. else:
  593. gc.DrawLine(inset, height, width, height)
  594. if self.passage.isImage():
  595. if self.bitmap:
  596. if isinstance(gc, wx.GraphicsContext):
  597. gc.ResetClip()
  598. gc.Clip(1, titleBarHeight + 1, size.width - 3, size.height - 3)
  599. else:
  600. gc.DestroyClippingRegion()
  601. gc.SetClippingRect(wx.Rect(1, titleBarHeight + 1, size.width - 3, size.height - 3))
  602. width = size.width
  603. height = size.height - titleBarHeight
  604. # choose smaller of vertical and horizontal scale factor, to preserve aspect ratio
  605. scale = min(width/float(self.bitmap.GetWidth()), height/float(self.bitmap.GetHeight()))
  606. img = self.bitmap.ConvertToImage()
  607. if scale != 1:
  608. img = img.Scale(scale*self.bitmap.GetWidth(),scale*self.bitmap.GetHeight())
  609. # offset image horizontally or vertically, to centre after scaling
  610. offsetWidth = (width - img.GetWidth())/2
  611. offsetHeight = (height - img.GetHeight())/2
  612. if isinstance(gc, wx.GraphicsContext):
  613. gc.DrawBitmap(img.ConvertToBitmap(self.bitmap.GetDepth()),
  614. 1 + offsetWidth, titleBarHeight + 1 + offsetHeight,
  615. img.GetWidth(), img.GetHeight())
  616. else:
  617. gc.DrawBitmap(img.ConvertToBitmap(self.bitmap.GetDepth()),
  618. 1 + offsetWidth, titleBarHeight + 1 + offsetHeight)
  619. if isinstance(gc, wx.GraphicsContext):
  620. gc.ResetClip()
  621. else:
  622. gc.DestroyClippingRegion()
  623. # draw a broken link emblem in the bottom right if necessary
  624. # fixme: not sure how to do this with transparency
  625. def showEmblem(emblem, gc=gc, size=size, inset=inset):
  626. emblemSize = emblem.GetSize()
  627. emblemPos = [ size.width - (emblemSize[0] + inset), \
  628. size.height - (emblemSize[1] + inset) ]
  629. if isinstance(gc, wx.GraphicsContext):
  630. gc.DrawBitmap(emblem, emblemPos[0], emblemPos[1], emblemSize[0], emblemSize[1])
  631. else:
  632. gc.DrawBitmap(emblem, emblemPos[0], emblemPos[1])
  633. if len(self.getBrokenLinks()):
  634. showEmblem(self.brokenEmblem)
  635. elif len(self.getIncludedLinks()) or len(self.passage.variableLinks):
  636. showEmblem(self.externalEmblem)
  637. # finally, draw a selection over ourselves if we're selected
  638. if self.selected:
  639. color = dim(titleBarColor if flat else wx.SystemSettings_GetColour(wx.SYS_COLOUR_HIGHLIGHT), self.dimmed)
  640. if self.app.config.ReadBool('fastStoryPanel'):
  641. gc.SetPen(wx.Pen(color, 2 + flat))
  642. else:
  643. gc.SetPen(wx.TRANSPARENT_PEN)
  644. if isinstance(gc, wx.GraphicsContext):
  645. r, g, b = color.Get(False)
  646. color = wx.Colour(r, g, b, 64)
  647. gc.SetBrush(wx.Brush(color))
  648. else:
  649. gc.SetBrush(wx.TRANSPARENT_BRUSH)
  650. gc.DrawRectangle(0, 0, size.width, size.height)
  651. self.paintBufferBounds = size
  652. def serialize(self):
  653. """Returns a dictionary with state information suitable for pickling."""
  654. return { 'selected': self.selected, 'pos': self.pos, 'passage': copy.copy(self.passage) }
  655. @staticmethod
  656. def posCompare(first, second):
  657. """
  658. Sorts PassageWidgets so that the results appear left to right,
  659. top to bottom. A certain amount of slack is assumed here in
  660. terms of positioning.
  661. """
  662. yDistance = int(first.pos[1] - second.pos[1])
  663. if abs(yDistance) > 5:
  664. return yDistance
  665. xDistance = int(first.pos[0] - second.pos[0])
  666. if xDistance != 0:
  667. return xDistance
  668. return id(first) - id(second) # punt on ties
  669. def __repr__(self):
  670. return "<PassageWidget '" + self.passage.title + "'>"
  671. def getHeader(self):
  672. """Returns the current selected target header for this Passage Widget."""
  673. return self.parent.getHeader()
  674. MIN_PIXEL_SIZE = 10
  675. MIN_GREEKING_SIZE = 50
  676. GREEK_HEIGHT = 2
  677. SIZE = 120
  678. SHADOW_SIZE = 5
  679. COLORS = {
  680. 'frame': (0, 0, 0), \
  681. 'bodyStart': (255, 255, 255), \
  682. 'bodyEnd': (212, 212, 212), \
  683. 'annotation': (85, 87, 83), \
  684. 'endTitleBar': (16, 51, 96), \
  685. 'titleBar': (52, 101, 164), \
  686. 'imageTitleBar': (8, 138, 133), \
  687. 'privateTitleBar': (130, 130, 130), \
  688. 'titleText': (255, 255, 255), \
  689. 'excerptText': (0, 0, 0), \
  690. 'annotationText': (255,255,255), \
  691. 'greek': (102, 102, 102),
  692. 'connector': (186, 189, 182),
  693. 'connectorDisplay': (132, 164, 189),
  694. 'connectorResource': (110, 112, 107),
  695. 'connectorAnnotation': (0, 0, 0),
  696. }
  697. FLAT_COLORS = {
  698. 'frame': (0, 0, 0),
  699. 'bodyStart': (255, 255, 255),
  700. 'bodyEnd': (255, 255, 255),
  701. 'annotation': (212, 212, 212),
  702. 'endTitleBar': (36, 54, 219),
  703. 'titleBar': (36, 115, 219),
  704. 'imageTitleBar': (36, 219, 213),
  705. 'privateTitleBar': (153, 153, 153),
  706. 'titleText': (255, 255, 255),
  707. 'excerptText': (96, 96, 96),
  708. 'annotationText': (0,0,0),
  709. 'greek': (192, 192, 192),
  710. 'connector': (143, 148, 137),
  711. 'connectorDisplay': (137, 193, 235),
  712. 'connectorResource': (186, 188, 185),
  713. 'connectorAnnotation': (255, 255, 255),
  714. 'selection': (28, 102, 176)
  715. }
  716. DIMMED_ALPHA = 0.5
  717. FLAT_DIMMED_ALPHA = 0.9
  718. LINE_SPACING = 1.2
  719. CONNECTOR_WIDTH = 2.0
  720. CONNECTOR_SELECTED_WIDTH = 5.0
  721. ARROWHEAD_LENGTH = 10
  722. MIN_ARROWHEAD_LENGTH = 5
  723. ARROWHEAD_ANGLE = math.pi / 6
  724. # contextual menu
  725. class PassageWidgetContext(wx.Menu):
  726. def __init__(self, parent):
  727. wx.Menu.__init__(self)
  728. self.parent = parent
  729. title = '"' + parent.passage.title + '"'
  730. if parent.passage.isStoryPassage():
  731. test = wx.MenuItem(self, wx.NewId(), 'Test Play From Here')
  732. self.AppendItem(test)
  733. self.Bind(wx.EVT_MENU, lambda e: self.parent.parent.parent.testBuild(startAt = parent.passage.title), id = test.GetId())
  734. edit = wx.MenuItem(self, wx.NewId(), 'Edit ' + title)
  735. self.AppendItem(edit)
  736. self.Bind(wx.EVT_MENU, self.parent.openEditor, id = edit.GetId())
  737. delete = wx.MenuItem(self, wx.NewId(), 'Delete ' + title)
  738. self.AppendItem(delete)
  739. self.Bind(wx.EVT_MENU, lambda e: self.parent.parent.removeWidget(self.parent.passage.title), id = delete.GetId())