passageframe.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137
  1. import sys, os, re, threading, wx, wx.lib.scrolledpanel, wx.animate, base64, tweeregex
  2. import metrics, images
  3. from version import versionString
  4. from tweelexer import TweeLexer
  5. from tweestyler import TweeStyler
  6. from tiddlywiki import TiddlyWiki
  7. from passagesearchframe import PassageSearchFrame
  8. from fseditframe import FullscreenEditFrame
  9. from utils import isURL
  10. import cStringIO
  11. class PassageFrame(wx.Frame):
  12. """
  13. A PassageFrame is a window that allows the user to change the contents
  14. of a passage. This must be paired with a PassageWidget; it gets to the
  15. underlying passage via it, and also notifies it of changes made here.
  16. This doesn't require the user to save their changes -- as they make
  17. changes, they are automatically updated everywhere.
  18. nb: This does not make use of wx.stc's built-in find/replace functions.
  19. This is partially for user interface reasons, as find/replace at the
  20. StoryPanel level uses Python regexps, not Scintilla ones. It's also
  21. because SearchPanel and ReplacePanel hand back regexps, so we wouldn't
  22. know what flags to pass to wx.stc.
  23. """
  24. def __init__(self, parent, widget, app):
  25. self.widget = widget
  26. self.app = app
  27. self.syncTimer = None
  28. self.lastFindRegexp = None
  29. self.lastFindFlags = None
  30. self.usingLexer = self.LEXER_NORMAL
  31. self.titleInvalid = False
  32. wx.Frame.__init__(self, parent, wx.ID_ANY, title = 'Untitled Passage - ' + self.app.NAME + ' ' + versionString, \
  33. size = PassageFrame.DEFAULT_SIZE)
  34. # Passage menu
  35. passageMenu = wx.Menu()
  36. passageMenu.Append(PassageFrame.PASSAGE_EDIT_SELECTION, 'Create &Link From Selection\tCtrl-L')
  37. self.Bind(wx.EVT_MENU, self.editSelection, id = PassageFrame.PASSAGE_EDIT_SELECTION)
  38. self.outLinksMenu = wx.Menu()
  39. self.outLinksMenuTitle = passageMenu.AppendMenu(wx.ID_ANY, 'Outgoing Links', self.outLinksMenu)
  40. self.inLinksMenu = wx.Menu()
  41. self.inLinksMenuTitle = passageMenu.AppendMenu(wx.ID_ANY, 'Incoming Links', self.inLinksMenu)
  42. self.brokenLinksMenu = wx.Menu()
  43. self.brokenLinksMenuTitle = passageMenu.AppendMenu(wx.ID_ANY, 'Broken Links', self.brokenLinksMenu)
  44. passageMenu.AppendSeparator()
  45. passageMenu.Append(wx.ID_SAVE, '&Save Story\tCtrl-S')
  46. self.Bind(wx.EVT_MENU, self.widget.parent.parent.save, id = wx.ID_SAVE)
  47. passageMenu.Append(PassageFrame.PASSAGE_VERIFY, '&Verify Passage\tCtrl-E')
  48. self.Bind(wx.EVT_MENU, lambda e: (self.widget.verifyPassage(self), self.offerAssistance()),\
  49. id = PassageFrame.PASSAGE_VERIFY)
  50. passageMenu.Append(PassageFrame.PASSAGE_TEST_HERE, '&Test Play From Here\tCtrl-T')
  51. self.Bind(wx.EVT_MENU, lambda e: self.widget.parent.parent.testBuild(e, startAt = self.widget.passage.title),\
  52. id = PassageFrame.PASSAGE_TEST_HERE)
  53. passageMenu.Append(PassageFrame.PASSAGE_REBUILD_STORY, '&Rebuild Story\tCtrl-R')
  54. self.Bind(wx.EVT_MENU, self.widget.parent.parent.rebuild, id = PassageFrame.PASSAGE_REBUILD_STORY)
  55. passageMenu.AppendSeparator()
  56. passageMenu.Append(PassageFrame.PASSAGE_FULLSCREEN, '&Fullscreen View\tF12')
  57. self.Bind(wx.EVT_MENU, self.openFullscreen, id = PassageFrame.PASSAGE_FULLSCREEN)
  58. passageMenu.Append(wx.ID_CLOSE, '&Close Passage\tCtrl-W')
  59. self.Bind(wx.EVT_MENU, lambda e: self.Close(), id = wx.ID_CLOSE)
  60. # Edit menu
  61. editMenu = wx.Menu()
  62. editMenu.Append(wx.ID_UNDO, '&Undo\tCtrl-Z')
  63. self.Bind(wx.EVT_MENU, lambda e: self.bodyInput.Undo(), id = wx.ID_UNDO)
  64. if sys.platform == 'darwin':
  65. shortcut = 'Ctrl-Shift-Z'
  66. else:
  67. shortcut = 'Ctrl-Y'
  68. editMenu.Append(wx.ID_REDO, '&Redo\t' + shortcut)
  69. self.Bind(wx.EVT_MENU, lambda e: self.bodyInput.Redo(), id = wx.ID_REDO)
  70. editMenu.AppendSeparator()
  71. editMenu.Append(wx.ID_CUT, 'Cu&t\tCtrl-X')
  72. self.Bind(wx.EVT_MENU, lambda e: wx.Window.FindFocus().Cut(), id = wx.ID_CUT)
  73. editMenu.Append(wx.ID_COPY, '&Copy\tCtrl-C')
  74. self.Bind(wx.EVT_MENU, lambda e: wx.Window.FindFocus().Copy(), id = wx.ID_COPY)
  75. editMenu.Append(wx.ID_PASTE, '&Paste\tCtrl-V')
  76. self.Bind(wx.EVT_MENU, lambda e: wx.Window.FindFocus().Paste(), id = wx.ID_PASTE)
  77. editMenu.Append(wx.ID_SELECTALL, 'Select &All\tCtrl-A')
  78. self.Bind(wx.EVT_MENU, lambda e: wx.Window.FindFocus().SelectAll(), id = wx.ID_SELECTALL)
  79. editMenu.AppendSeparator()
  80. editMenu.Append(wx.ID_FIND, '&Find...\tCtrl-F')
  81. self.Bind(wx.EVT_MENU, lambda e: self.showSearchFrame(PassageSearchFrame.FIND_TAB), id = wx.ID_FIND)
  82. editMenu.Append(PassageFrame.EDIT_FIND_NEXT, 'Find &Next\tCtrl-G')
  83. self.Bind(wx.EVT_MENU, self.findNextRegexp, id = PassageFrame.EDIT_FIND_NEXT)
  84. if sys.platform == 'darwin':
  85. shortcut = 'Ctrl-Shift-H'
  86. else:
  87. shortcut = 'Ctrl-H'
  88. editMenu.Append(wx.ID_REPLACE, '&Replace...\t' + shortcut)
  89. self.Bind(wx.EVT_MENU, lambda e: self.showSearchFrame(PassageSearchFrame.REPLACE_TAB), id = wx.ID_REPLACE)
  90. # help menu
  91. helpMenu = wx.Menu()
  92. if self.widget.passage.isStylesheet():
  93. helpMenu.Append(PassageFrame.HELP1, 'About Stylesheets')
  94. self.Bind(wx.EVT_MENU, lambda e: wx.LaunchDefaultBrowser('http://twinery.org/wiki/stylesheet'), id = PassageFrame.HELP1)
  95. elif self.widget.passage.isScript():
  96. helpMenu.Append(PassageFrame.HELP1, 'About Scripts')
  97. self.Bind(wx.EVT_MENU, lambda e: wx.LaunchDefaultBrowser('http://twinery.org/wiki/script'), id = PassageFrame.HELP1)
  98. else:
  99. helpMenu.Append(PassageFrame.HELP1, 'About Passages')
  100. self.Bind(wx.EVT_MENU, lambda e: wx.LaunchDefaultBrowser('http://twinery.org/wiki/passage'), id = PassageFrame.HELP1)
  101. helpMenu.Append(PassageFrame.HELP2, 'About Text Syntax')
  102. self.Bind(wx.EVT_MENU, lambda e: wx.LaunchDefaultBrowser('http://twinery.org/wiki/syntax'), id = PassageFrame.HELP2)
  103. helpMenu.Append(PassageFrame.HELP3, 'About Links')
  104. self.Bind(wx.EVT_MENU, lambda e: wx.LaunchDefaultBrowser('http://twinery.org/wiki/link'), id = PassageFrame.HELP3)
  105. helpMenu.Append(PassageFrame.HELP4, 'About Macros')
  106. self.Bind(wx.EVT_MENU, lambda e: wx.LaunchDefaultBrowser('http://twinery.org/wiki/macro'), id = PassageFrame.HELP4)
  107. helpMenu.Append(PassageFrame.HELP5, 'About Tags')
  108. self.Bind(wx.EVT_MENU, lambda e: wx.LaunchDefaultBrowser('http://twinery.org/wiki/tag'), id = PassageFrame.HELP5)
  109. # menus
  110. self.menus = wx.MenuBar()
  111. self.menus.Append(passageMenu, '&Passage')
  112. self.menus.Append(editMenu, '&Edit')
  113. self.menus.Append(helpMenu, '&Help')
  114. self.SetMenuBar(self.menus)
  115. # controls
  116. self.panel = wx.Panel(self)
  117. allSizer = wx.BoxSizer(wx.VERTICAL)
  118. self.panel.SetSizer(allSizer)
  119. # title/tag controls
  120. self.topControls = wx.Panel(self.panel)
  121. topSizer = wx.FlexGridSizer(3, 2, metrics.size('relatedControls'), metrics.size('relatedControls'))
  122. self.titleLabel = wx.StaticText(self.topControls, style = wx.ALIGN_RIGHT, label = PassageFrame.TITLE_LABEL)
  123. self.titleInput = wx.TextCtrl(self.topControls)
  124. tagsLabel = wx.StaticText(self.topControls, style = wx.ALIGN_RIGHT, label = PassageFrame.TAGS_LABEL)
  125. self.tagsInput = wx.TextCtrl(self.topControls)
  126. topSizer.Add(self.titleLabel, 0, flag = wx.ALL, border = metrics.size('focusRing'))
  127. topSizer.Add(self.titleInput, 1, flag = wx.EXPAND | wx.ALL, border = metrics.size('focusRing'))
  128. topSizer.Add(tagsLabel, 0, flag = wx.ALL, border = metrics.size('focusRing'))
  129. topSizer.Add(self.tagsInput, 1, flag = wx.EXPAND | wx.ALL, border = metrics.size('focusRing'))
  130. topSizer.AddGrowableCol(1, 1)
  131. self.topControls.SetSizer(topSizer)
  132. # body text
  133. self.bodyInput = wx.stc.StyledTextCtrl(self.panel, style = wx.TE_PROCESS_TAB | wx.BORDER_SUNKEN)
  134. self.bodyInput.SetUseHorizontalScrollBar(False)
  135. self.bodyInput.SetMargins(8, 8)
  136. self.bodyInput.SetMarginWidth(1, 0)
  137. self.bodyInput.SetTabWidth(4)
  138. self.bodyInput.SetWrapMode(wx.stc.STC_WRAP_WORD)
  139. self.bodyInput.SetSelBackground(True, wx.SystemSettings_GetColour(wx.SYS_COLOUR_HIGHLIGHT))
  140. self.bodyInput.SetSelForeground(True, wx.SystemSettings_GetColour(wx.SYS_COLOUR_HIGHLIGHTTEXT))
  141. self.bodyInput.SetFocus()
  142. # The default keyboard shortcuts for StyledTextCtrl are
  143. # nonstandard on Mac OS X
  144. if sys.platform == "darwin":
  145. # cmd-left/right to move to beginning/end of line
  146. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_LEFT, wx.stc.STC_SCMOD_CTRL, wx.stc.STC_CMD_HOMEDISPLAY)
  147. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_LEFT, wx.stc.STC_SCMOD_CTRL | wx.stc.STC_SCMOD_SHIFT, wx.stc.STC_CMD_HOMEDISPLAYEXTEND)
  148. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_RIGHT, wx.stc.STC_SCMOD_CTRL, wx.stc.STC_CMD_LINEENDDISPLAY)
  149. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_RIGHT, wx.stc.STC_SCMOD_CTRL | wx.stc.STC_SCMOD_SHIFT, wx.stc.STC_CMD_LINEENDDISPLAYEXTEND)
  150. # opt-left/right to move forward/back a word
  151. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_LEFT, wx.stc.STC_SCMOD_ALT, wx.stc.STC_CMD_WORDLEFT)
  152. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_LEFT, wx.stc.STC_SCMOD_ALT | wx.stc.STC_SCMOD_SHIFT, wx.stc.STC_CMD_WORDLEFTEXTEND)
  153. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_RIGHT, wx.stc.STC_SCMOD_ALT, wx.stc.STC_CMD_WORDRIGHT)
  154. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_RIGHT, wx.stc.STC_SCMOD_ALT | wx.stc.STC_SCMOD_SHIFT, wx.stc.STC_CMD_WORDRIGHTEXTEND)
  155. # cmd-delete to delete from the cursor to beginning of line
  156. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_BACK, wx.stc.STC_SCMOD_CTRL, wx.stc.STC_CMD_DELLINELEFT)
  157. # opt-delete to delete the previous/current word
  158. self.bodyInput.CmdKeyAssign(wx.stc.STC_KEY_BACK, wx.stc.STC_SCMOD_ALT, wx.stc.STC_CMD_DELWORDLEFT)
  159. # cmd-shift-z to redo
  160. self.bodyInput.CmdKeyAssign(ord('Z'), wx.stc.STC_SCMOD_CTRL | wx.stc.STC_SCMOD_SHIFT, wx.stc.STC_CMD_REDO)
  161. # final layout
  162. allSizer.Add(self.topControls, flag = wx.TOP | wx.LEFT | wx.RIGHT | wx.EXPAND, border = metrics.size('windowBorder'))
  163. allSizer.Add(self.bodyInput, proportion = 1, flag = wx.TOP | wx.EXPAND, border = metrics.size('relatedControls'))
  164. self.lexer = TweeStyler(self.bodyInput, self)
  165. self.applyPrefs()
  166. self.syncInputs()
  167. self.bodyInput.EmptyUndoBuffer()
  168. self.updateSubmenus()
  169. self.setLexer()
  170. # event bindings
  171. # we need to do this AFTER setting up initial values
  172. self.titleInput.Bind(wx.EVT_TEXT, self.syncPassage)
  173. self.tagsInput.Bind(wx.EVT_TEXT, self.syncPassage)
  174. self.bodyInput.Bind(wx.stc.EVT_STC_CHANGE, self.syncPassage)
  175. self.bodyInput.Bind(wx.stc.EVT_STC_START_DRAG, self.prepDrag)
  176. self.Bind(wx.EVT_CLOSE, self.closeEditor)
  177. self.Bind(wx.EVT_MENU_OPEN, self.updateSubmenus)
  178. self.Bind(wx.EVT_UPDATE_UI, self.updateUI)
  179. if not re.match('Untitled Passage \d+', self.widget.passage.title):
  180. self.bodyInput.SetFocus()
  181. self.bodyInput.SetSelection(-1, -1)
  182. # Hack to force titles (>18 char) to display correctly.
  183. # NOTE: stops working if moved above bodyInput code.
  184. self.titleInput.SetInsertionPoint(0)
  185. self.SetIcon(self.app.icon)
  186. self.Show(True)
  187. def title(self, title = None):
  188. if not title:
  189. title = self.widget.passage.title
  190. return title + ' - ' + self.widget.parent.parent.title + ' - ' + self.app.NAME + ' ' + versionString
  191. def syncInputs(self):
  192. """Updates the inputs based on the passage's state."""
  193. self.titleInput.SetValue(self.widget.passage.title)
  194. self.bodyInput.SetText(self.widget.passage.text)
  195. tags = ''
  196. for tag in self.widget.passage.tags:
  197. tags += tag + ' '
  198. self.tagsInput.SetValue(tags)
  199. self.SetTitle(self.title())
  200. def syncPassage(self, event = None):
  201. """Updates the passage based on the inputs; asks our matching widget to repaint."""
  202. title = self.titleInput.GetValue() if len(self.titleInput.GetValue()) > 0 else ""
  203. title = title.replace('\n','')
  204. def error():
  205. self.titleInput.SetBackgroundColour((240,130,130))
  206. self.titleInput.Refresh()
  207. self.titleInvalid = True
  208. if title:
  209. # Check for title conflict
  210. otherTitled = self.widget.parent.findWidget(title)
  211. # WARNING: findWidget returns None if title not found so need to check otherTitled has value.
  212. if otherTitled and otherTitled is not self.widget:
  213. self.titleLabel.SetLabel("Title is already in use!")
  214. error()
  215. elif self.widget.parent.includedPassageExists(title):
  216. self.titleLabel.SetLabel("Used by a StoryIncludes file.")
  217. error()
  218. elif "|" in title or "]" in title:
  219. self.titleLabel.SetLabel("No | or ] symbols allowed!")
  220. error()
  221. elif title == "StorySettings":
  222. self.titleLabel.SetLabel("That title is reserved.")
  223. error()
  224. else:
  225. if self.titleInvalid:
  226. self.titleLabel.SetLabel(self.TITLE_LABEL)
  227. self.titleInput.SetBackgroundColour((255,255,255))
  228. self.titleInput.Refresh()
  229. self.titleInvalid = True
  230. self.widget.parent.changeWidgetTitle(self.widget.passage.title, title)
  231. # Set body text
  232. self.widget.passage.text = self.bodyInput.GetText()
  233. # Preserve the special (uneditable) tags
  234. self.widget.passage.tags = []
  235. self.widget.clearPaintCache()
  236. for tag in self.tagsInput.GetValue().split(' '):
  237. if tag != '' and tag not in TiddlyWiki.SPECIAL_TAGS:
  238. self.widget.passage.tags.append(tag)
  239. if tag == "StoryIncludes" and self.widget.parent.parent.autobuildmenuitem.IsChecked():
  240. self.widget.parent.parent.autoBuildStart()
  241. self.SetTitle(self.title())
  242. # immediately mark the story dirty
  243. self.widget.parent.parent.setDirty(True)
  244. # reposition if changed size
  245. self.widget.findSpace()
  246. # reset redraw timer
  247. def reallySync(self):
  248. try:
  249. self.widget.parent.Refresh()
  250. except:
  251. pass
  252. if self.syncTimer:
  253. self.syncTimer.cancel()
  254. self.syncTimer = threading.Timer(PassageFrame.PARENT_SYNC_DELAY, reallySync, [self], {})
  255. self.syncTimer.start()
  256. # update links/displays lists
  257. self.widget.passage.update()
  258. # change our lexer as necessary
  259. self.setLexer()
  260. def openFullscreen(self, event = None):
  261. """Opens a FullscreenEditFrame for this passage's body text."""
  262. self.Hide()
  263. self.fullscreen = FullscreenEditFrame(None, self.app, \
  264. title = self.title(), \
  265. initialText = self.widget.passage.text, \
  266. callback = self.setBodyText, frame = self)
  267. def offerAssistance(self):
  268. """
  269. Offer to fulfill certain incomplete tasks evident from the state of the passage text.
  270. (Technically, none of this needs to be on passageFrame instead of passageWidget.)
  271. """
  272. # Offer to create passage for broken links
  273. if self.app.config.ReadBool('createPassagePrompt'):
  274. brokens = [link for link in self.widget.getBrokenLinks() if TweeLexer.linkStyle(link) == TweeLexer.BAD_LINK]
  275. if brokens :
  276. if len(brokens) > 1:
  277. brokenmsg = 'create ' + str(len(brokens)) + ' new passages to match these broken links?'
  278. else:
  279. brokenmsg = 'create the passage "' + brokens[0] + '"?'
  280. dialog = wx.MessageDialog(self, 'Do you want to ' + brokenmsg, 'Create Passages', \
  281. wx.ICON_QUESTION | wx.YES_NO | wx.CANCEL | wx.YES_DEFAULT)
  282. check = dialog.ShowModal()
  283. if check == wx.ID_YES:
  284. for title in brokens:
  285. self.widget.parent.newWidget(title = title, pos = self.widget.parent.toPixels (self.widget.pos))
  286. elif check == wx.ID_CANCEL:
  287. return False
  288. # Offer to import external images
  289. if self.app.config.ReadBool('importImagePrompt'):
  290. regex = tweeregex.EXTERNAL_IMAGE_REGEX
  291. externalimages = re.finditer(regex, self.widget.passage.text)
  292. check = None
  293. downloadedurls = {}
  294. storyframe = self.widget.parent.parent
  295. for img in externalimages:
  296. if not check:
  297. dialog = wx.MessageDialog(self, 'Do you want to import the image files linked\nin this passage into the story file?', 'Import Images', \
  298. wx.ICON_QUESTION | wx.YES_NO | wx.CANCEL | wx.YES_DEFAULT)
  299. check = dialog.ShowModal()
  300. if check == wx.ID_NO:
  301. break
  302. elif check == wx.ID_CANCEL:
  303. return False
  304. # Download the image if it's at an absolute URL
  305. imgurl = img.group(4) or img.group(7)
  306. if not imgurl:
  307. continue
  308. # If we've downloaded it before, don't do it again
  309. if imgurl not in downloadedurls:
  310. # Internet image, or local image?
  311. if isURL(imgurl):
  312. imgpassagename = storyframe.importImageURL(imgurl, showdialog=False)
  313. else:
  314. imgpassagename = storyframe.importImageFile(storyframe.getLocalDir()+os.sep+imgurl, showdialog=False)
  315. if not imgpassagename:
  316. continue
  317. downloadedurls[imgurl] = imgpassagename
  318. # Replace all found images
  319. for old, new in downloadedurls.iteritems():
  320. self.widget.passage.text = re.sub(regex.replace(tweeregex.IMAGE_FILENAME_REGEX, re.escape(old)),
  321. lambda m: m.group(0).replace(old, new), self.widget.passage.text)
  322. if self.bodyInput.GetText() != self.widget.passage.text:
  323. self.bodyInput.SetText(self.widget.passage.text)
  324. # If it's StoryIncludes, update the links
  325. if self.widget.passage.title == "StoryIncludes":
  326. self.widget.parent.refreshIncludedPassageList()
  327. return True
  328. def closeEditor(self, event = None):
  329. """
  330. Do extra stuff on closing the editor
  331. """
  332. # Show warnings, do replacements
  333. if self.app.config.ReadBool('passageWarnings'):
  334. if self.widget.verifyPassage(self) == -1: return
  335. # Do help
  336. if not self.offerAssistance():
  337. return
  338. #Closes this editor's fullscreen counterpart, if any.
  339. try: self.fullscreen.Destroy()
  340. except: pass
  341. self.widget.passage.update()
  342. event.Skip()
  343. def openOtherEditor(self, event = None, title = None):
  344. """
  345. Opens another passage for editing. If it does not exist, then
  346. it creates it next to this one and then opens it. You may pass
  347. this a string title OR an event. If you pass an event, it presumes
  348. it is a wx.CommandEvent, and uses the exact text of the menu as the title.
  349. """
  350. # we seem to be receiving CommandEvents, not MenuEvents,
  351. # so we can only see menu item IDs
  352. # unfortunately all our menu items are dynamically generated
  353. # so we gotta work our way back to a menu name
  354. if not title: title = self.menus.FindItemById(event.GetId()).GetLabel()
  355. # check if the passage already exists
  356. editingWidget = self.widget.parent.findWidget(title)
  357. if not editingWidget:
  358. editingWidget = self.widget.parent.newWidget(title = title, pos = self.widget.parent.toPixels (self.widget.pos))
  359. editingWidget.openEditor()
  360. def showSearchFrame(self, type):
  361. """
  362. Shows a PassageSearchFrame for this frame, creating it if need be.
  363. The type parameter should be one of the constants defined in
  364. PassageSearchFrame, e.g. FIND_TAB or REPLACE_TAB.
  365. """
  366. if not hasattr(self, 'searchFrame'):
  367. self.searchFrame = PassageSearchFrame(self, self, self.app, type)
  368. else:
  369. try:
  370. self.searchFrame.Raise()
  371. except wx._core.PyDeadObjectError:
  372. # user closed the frame, so we need to recreate it
  373. delattr(self, 'searchFrame')
  374. self.showSearchFrame(type)
  375. def setBodyText(self, text):
  376. """Changes the body text field directly."""
  377. self.bodyInput.SetText(text)
  378. self.Show(True)
  379. def prepDrag(self, event):
  380. """
  381. Tells our StoryPanel about us so that it can tell us what to do in response to
  382. dropping some text into it.
  383. """
  384. event.SetDragAllowMove(True)
  385. self.widget.parent.textDragSource = self
  386. def getSelection(self):
  387. """
  388. Returns the beginning and end of the selection as a tuple.
  389. """
  390. return self.bodyInput.GetSelection()
  391. def getSelectedText(self):
  392. """
  393. Returns the text currently selected.
  394. """
  395. return self.bodyInput.GetSelectedText()
  396. def setSelection(self, range):
  397. """
  398. Changes the current selection to the range passed.
  399. """
  400. self.bodyInput.SetSelection(range[0], range[1])
  401. def editSelection(self, event = None):
  402. """
  403. If the selection isn't already double-bracketed, then brackets are added.
  404. If a passage with the selection title doesn't exist, it is created.
  405. Finally, an editor is opened for the passage.
  406. """
  407. rawSelection = self.bodyInput.GetSelectedText()
  408. title = self.stripCrud(rawSelection)
  409. if not re.match(r'^\[\[.*\]\]$', rawSelection): self.linkSelection()
  410. self.openOtherEditor(title = title)
  411. self.updateSubmenus()
  412. def linkSelection(self):
  413. """Transforms the selection into a link by surrounding it with double brackets."""
  414. selStart = self.bodyInput.GetSelectionStart()
  415. selEnd = self.bodyInput.GetSelectionEnd()
  416. self.bodyInput.SetSelection(selStart, selEnd)
  417. self.bodyInput.ReplaceSelection("[["+self.bodyInput.GetSelectedText()+"]]")
  418. def findRegexp(self, regexp, flags):
  419. """
  420. Selects a regexp in the body text.
  421. """
  422. self.lastFindRegexp = regexp
  423. self.lastFindFlags = flags
  424. # find the beginning of our search
  425. text = self.bodyInput.GetText()
  426. oldSelection = self.bodyInput.GetSelection()
  427. # try past the selection
  428. match = re.search(regexp, text[oldSelection[1]:], flags)
  429. if match:
  430. self.bodyInput.SetSelection(match.start() + oldSelection[1], match.end() + oldSelection[1])
  431. else:
  432. # try before the selection
  433. match = re.search(regexp, text[:oldSelection[1]], flags)
  434. if match:
  435. self.bodyInput.SetSelection(match.start(), match.end())
  436. else:
  437. # give up
  438. dialog = wx.MessageDialog(self, 'The text you entered was not found in this passage.', \
  439. 'Not Found', wx.ICON_INFORMATION | wx.OK)
  440. dialog.ShowModal()
  441. def findNextRegexp(self, event = None):
  442. """
  443. Performs a search for the last regexp that was searched for.
  444. """
  445. self.findRegexp(self.lastFindRegexp, self.lastFindFlags)
  446. def replaceOneRegexp(self, findRegexp, flags, replaceRegexp):
  447. """
  448. If the current selection matches the search regexp, a replacement
  449. is made. Otherwise, it calls findRegexp().
  450. """
  451. compiledRegexp = re.compile(findRegexp, flags)
  452. selectedText = self.bodyInput.GetSelectedText()
  453. match = re.match(findRegexp, selectedText, flags)
  454. if match and match.endpos == len(selectedText):
  455. oldStart = self.bodyInput.GetSelectionStart()
  456. newText = re.sub(compiledRegexp, replaceRegexp, selectedText)
  457. self.bodyInput.ReplaceSelection(newText)
  458. self.bodyInput.SetSelection(oldStart, oldStart + len(newText))
  459. else:
  460. # look for the next instance
  461. self.findRegexp(findRegexp, flags)
  462. def replaceAllRegexps(self, findRegexp, flags, replaceRegexp):
  463. """
  464. Replaces all instances of text in the body text and
  465. shows the user an alert about how many replacements
  466. were made.
  467. """
  468. replacements = 0
  469. compiledRegexp = re.compile(findRegexp, flags)
  470. newText, replacements = re.subn(compiledRegexp, replaceRegexp, self.bodyInput.GetText())
  471. if replacements > 0: self.bodyInput.SetText(newText)
  472. message = '%d replacement' % replacements
  473. if replacements != 1:
  474. message += 's were '
  475. else:
  476. message += ' was '
  477. message += 'made in this passage.'
  478. dialog = wx.MessageDialog(self, message, 'Replace Complete', wx.ICON_INFORMATION | wx.OK)
  479. dialog.ShowModal()
  480. def stripCrud(self, text):
  481. """Strips extraneous crud from around text, likely a partial selection of a link."""
  482. return text.strip(""" "'<>[]""")
  483. def setCodeLexer(self, css = False):
  484. """Basic CSS highlighting"""
  485. monoFont = wx.Font(self.app.config.ReadInt('monospaceFontSize'), wx.MODERN, wx.NORMAL, \
  486. wx.NORMAL, False, self.app.config.Read('monospaceFontFace'))
  487. body = self.bodyInput
  488. body.StyleSetFont(wx.stc.STC_STYLE_DEFAULT, monoFont)
  489. body.StyleClearAll()
  490. if css:
  491. for i in range(1,17):
  492. body.StyleSetFont(i, monoFont)
  493. body.StyleSetForeground(wx.stc.STC_CSS_IMPORTANT, TweeStyler.MACRO_COLOR)
  494. body.StyleSetForeground(wx.stc.STC_CSS_COMMENT, TweeStyler.COMMENT_COLOR)
  495. body.StyleSetForeground(wx.stc.STC_CSS_ATTRIBUTE, TweeStyler.GOOD_LINK_COLOR)
  496. body.StyleSetForeground(wx.stc.STC_CSS_CLASS, TweeStyler.MARKUP_COLOR)
  497. body.StyleSetForeground(wx.stc.STC_CSS_ID, TweeStyler.MARKUP_COLOR)
  498. body.StyleSetForeground(wx.stc.STC_CSS_TAG, TweeStyler.PARAM_BOOL_COLOR)
  499. body.StyleSetForeground(wx.stc.STC_CSS_PSEUDOCLASS, TweeStyler.EXTERNAL_COLOR)
  500. body.StyleSetForeground(wx.stc.STC_CSS_UNKNOWN_PSEUDOCLASS, TweeStyler.EXTERNAL_COLOR)
  501. body.StyleSetForeground(wx.stc.STC_CSS_DIRECTIVE, TweeStyler.PARAM_VAR_COLOR)
  502. body.StyleSetForeground(wx.stc.STC_CSS_UNKNOWN_IDENTIFIER, TweeStyler.GOOD_LINK_COLOR)
  503. for i in [wx.stc.STC_CSS_CLASS, wx.stc.STC_CSS_ID, wx.stc.STC_CSS_TAG,
  504. wx.stc.STC_CSS_PSEUDOCLASS, wx.stc.STC_CSS_OPERATOR, wx.stc.STC_CSS_IMPORTANT,
  505. wx.stc.STC_CSS_UNKNOWN_PSEUDOCLASS, wx.stc.STC_CSS_DIRECTIVE]:
  506. body.StyleSetBold(i, True)
  507. def setLexer(self):
  508. """
  509. Sets our custom lexer for the body input so long as the passage
  510. is part of the story.
  511. """
  512. oldLexing = self.usingLexer
  513. if self.widget.passage.isStylesheet():
  514. if oldLexing != self.LEXER_CSS:
  515. self.setCodeLexer(css = True)
  516. self.usingLexer = self.LEXER_CSS
  517. self.bodyInput.SetLexer(wx.stc.STC_LEX_CSS)
  518. elif not self.widget.passage.isStoryText() and not self.widget.passage.isAnnotation():
  519. if oldLexing != self.LEXER_NONE:
  520. self.usingLexer = self.LEXER_NONE
  521. self.setCodeLexer()
  522. self.bodyInput.SetLexer(wx.stc.STC_LEX_NULL)
  523. elif oldLexing != self.LEXER_NORMAL:
  524. self.usingLexer = self.LEXER_NORMAL
  525. self.bodyInput.SetLexer(wx.stc.STC_LEX_CONTAINER)
  526. if oldLexing != self.usingLexer:
  527. if self.usingLexer == self.LEXER_NORMAL:
  528. self.lexer.initStyles()
  529. self.bodyInput.Colourise(0, len(self.bodyInput.GetText()))
  530. def updateUI(self, event):
  531. """Updates menus."""
  532. # basic edit menus
  533. undoItem = self.menus.FindItemById(wx.ID_UNDO)
  534. undoItem.Enable(self.bodyInput.CanUndo())
  535. redoItem = self.menus.FindItemById(wx.ID_REDO)
  536. redoItem.Enable(self.bodyInput.CanRedo())
  537. hasSelection = self.bodyInput.GetSelectedText() != ''
  538. cutItem = self.menus.FindItemById(wx.ID_CUT)
  539. cutItem.Enable(hasSelection)
  540. copyItem = self.menus.FindItemById(wx.ID_COPY)
  541. copyItem.Enable(hasSelection)
  542. pasteItem = self.menus.FindItemById(wx.ID_PASTE)
  543. pasteItem.Enable(self.bodyInput.CanPaste())
  544. # find/replace
  545. findNextItem = self.menus.FindItemById(PassageFrame.EDIT_FIND_NEXT)
  546. findNextItem.Enable(self.lastFindRegexp is not None)
  547. # link selected text menu item
  548. editSelected = self.menus.FindItemById(PassageFrame.PASSAGE_EDIT_SELECTION)
  549. selection = self.bodyInput.GetSelectedText()
  550. if selection != '':
  551. if not re.match(r'^\[\[.*\]\]$', selection):
  552. if len(selection) < 25:
  553. editSelected.SetText('Create &Link "' + selection + '"\tCtrl-L')
  554. else:
  555. editSelected.SetText('Create &Link From Selected Text\tCtrl-L')
  556. else:
  557. if len(selection) < 25:
  558. editSelected.SetText('&Edit Passage "' + self.stripCrud(selection) + '"\tCtrl-L')
  559. else:
  560. editSelected.SetText('&Edit Passage From Selected Text\tCtrl-L')
  561. editSelected.Enable(True)
  562. else:
  563. editSelected.SetText('Create &Link From Selected Text\tCtrl-L')
  564. editSelected.Enable(False)
  565. def updateSubmenus(self, event = None):
  566. """
  567. Updates our passage menus. This should be called sparingly, i.e. not during
  568. a UI update event, as it is doing a bunch of removing and adding of items.
  569. """
  570. # separate outgoing and broken links
  571. outgoing = []
  572. incoming = []
  573. broken = []
  574. # Remove externals
  575. for link in self.widget.passage.links:
  576. if len(link) > 0 and TweeLexer.linkStyle(link) == TweeLexer.BAD_LINK:
  577. if link in self.widget.parent.widgetDict:
  578. outgoing.append(link)
  579. elif not self.widget.parent.includedPassageExists(link):
  580. broken.append(link)
  581. # incoming links
  582. for widget in self.widget.parent.widgetDict.itervalues():
  583. if self.widget.passage.title in widget.passage.links \
  584. and len(widget.passage.title) > 0:
  585. incoming.append(widget.passage.title)
  586. # repopulate the menus
  587. def populate(menu, links):
  588. for item in menu.GetMenuItems():
  589. menu.DeleteItem(item)
  590. if len(links):
  591. for link in links:
  592. item = menu.Append(-1, link)
  593. self.Bind(wx.EVT_MENU, self.openOtherEditor, item)
  594. else:
  595. item = menu.Append(wx.ID_ANY, '(None)')
  596. item.Enable(False)
  597. outTitle = 'Outgoing Links'
  598. if len(outgoing) > 0: outTitle += ' (' + str(len(outgoing)) + ')'
  599. self.outLinksMenuTitle.SetText(outTitle)
  600. populate(self.outLinksMenu, outgoing)
  601. inTitle = 'Incoming Links'
  602. if len(incoming) > 0: inTitle += ' (' + str(len(incoming)) + ')'
  603. self.inLinksMenuTitle.SetText(inTitle)
  604. populate(self.inLinksMenu, incoming)
  605. brokenTitle = 'Broken Links'
  606. if len(broken) > 0: brokenTitle += ' (' + str(len(broken)) + ')'
  607. self.brokenLinksMenuTitle.SetText(brokenTitle)
  608. populate(self.brokenLinksMenu, broken)
  609. def applyPrefs(self):
  610. """Applies user prefs to this frame."""
  611. bodyFont = wx.Font(self.app.config.ReadInt('windowedFontSize'), wx.MODERN, wx.NORMAL,
  612. wx.NORMAL, False, self.app.config.Read('windowedFontFace'))
  613. defaultStyle = self.bodyInput.GetStyleAt(0)
  614. self.bodyInput.StyleSetFont(defaultStyle, bodyFont)
  615. if hasattr(self, 'lexer'): self.lexer.initStyles()
  616. def __repr__(self):
  617. return "<PassageFrame '" + self.widget.passage.title + "'>"
  618. def getHeader(self):
  619. """Returns the current selected target header for this Passage Frame."""
  620. return self.widget.getHeader()
  621. # timing constants
  622. PARENT_SYNC_DELAY = 0.5
  623. # control constants
  624. DEFAULT_SIZE = (550, 600)
  625. TITLE_LABEL = 'Title'
  626. TAGS_LABEL = 'Tags (separate with spaces)'
  627. # menu constants (not defined by wx)
  628. EDIT_FIND_NEXT = 2001
  629. [PASSAGE_FULLSCREEN, PASSAGE_EDIT_SELECTION, PASSAGE_REBUILD_STORY, PASSAGE_TEST_HERE, PASSAGE_VERIFY] = range(1001,1006)
  630. [HELP1, HELP2, HELP3, HELP4, HELP5] = range(3001,3006)
  631. [LEXER_NONE, LEXER_NORMAL, LEXER_CSS] = range(0,3)
  632. class StorySettingsFrame(PassageFrame):
  633. """A window which presents the current header's StorySettings."""
  634. def __init__(self, parent, widget, app):
  635. self.widget = widget
  636. self.app = app
  637. wx.Frame.__init__(self, parent, wx.ID_ANY, title = self.widget.passage.title + ' - ' + self.app.NAME + ' ' + versionString, \
  638. size = (metrics.size('storySettingsWidth'), metrics.size('storySettingsHeight')), style=wx.DEFAULT_FRAME_STYLE)
  639. # menus
  640. self.menus = wx.MenuBar()
  641. self.SetMenuBar(self.menus)
  642. # controls
  643. self.panel = wx.lib.scrolledpanel.ScrolledPanel(self)
  644. self.panel.SetupScrolling()
  645. allSizer = wx.BoxSizer(wx.VERTICAL)
  646. self.panel.SetSizer(allSizer)
  647. # Read the storysettings definitions for this header
  648. self.storySettingsData = self.widget.parent.parent.header.storySettings()
  649. if not self.storySettingsData or type(self.storySettingsData) is str:
  650. label = self.storySettingsData or "The currently selected story format does not use StorySettings."
  651. allSizer.Add(wx.StaticText(self.panel, label = label),flag=wx.ALL|wx.EXPAND, border=metrics.size('windowBorder'))
  652. self.storySettingsData = {}
  653. self.ctrls = {}
  654. for data in self.storySettingsData:
  655. ctrlset = []
  656. name = ''
  657. if data["type"] == "checkbox":
  658. checkbox = wx.CheckBox(self.panel, label = data["label"])
  659. name = data["name"]
  660. # Read current value, and default it if it's not present
  661. currentValue = self.getSetting(name).lower()
  662. if not currentValue:
  663. currentValue = data.get('default', 'off')
  664. self.saveSetting(name, currentValue)
  665. checkbox.SetValue(currentValue not in ["off", "false", '0'])
  666. values = data.get("values", ("on","off")) # pylint: disable=unused-variable
  667. checkbox.Bind(wx.EVT_CHECKBOX, lambda e, checkbox=checkbox, name=name, values=values:
  668. self.saveSetting(name, values[0] if checkbox.GetValue() else values[1] ))
  669. allSizer.Add(checkbox,flag=wx.ALL, border=metrics.size('windowBorder'))
  670. ctrlset.append(checkbox)
  671. elif data["type"] == "text":
  672. textlabel = wx.StaticText(self.panel, label = data["label"])
  673. textctrl = wx.TextCtrl(self.panel)
  674. name = data["name"]
  675. # Read current value
  676. currentValue = self.getSetting(name).lower()
  677. if not currentValue:
  678. currentValue = data.get('default', '')
  679. self.saveSetting(name, currentValue)
  680. textctrl.SetValue(currentValue or data.get("default",''))
  681. textctrl.Bind(wx.EVT_TEXT, lambda e, name=name, textctrl=textctrl:
  682. self.saveSetting(name,textctrl.GetValue()))
  683. # Setup sizer for label/textctrl pair
  684. hSizer = wx.BoxSizer(wx.HORIZONTAL)
  685. hSizer.Add(textlabel,1,wx.ALIGN_RIGHT|wx.ALIGN_CENTER_VERTICAL)
  686. hSizer.Add(textctrl,1,wx.EXPAND)
  687. allSizer.Add(hSizer,flag=wx.ALL|wx.EXPAND, border=metrics.size('windowBorder'))
  688. ctrlset += [textlabel, textctrl]
  689. else:
  690. continue
  691. if "desc" in data:
  692. desc = wx.StaticText(self.panel, label = data["desc"])
  693. allSizer.Add(desc, 0, flag=wx.LEFT|wx.BOTTOM, border = metrics.size('windowBorder'))
  694. ctrlset.append(desc)
  695. self.ctrls[name] = ctrlset
  696. self.SetIcon(self.app.icon)
  697. self.SetTitle(self.title())
  698. self.Layout()
  699. self.enableCtrls()
  700. self.Show(True)
  701. def enableCtrls(self):
  702. # Check if each ctrl has a requirement or an incompatibility,
  703. # look it up, and enable/disable if so
  704. for data in self.storySettingsData:
  705. name = data["name"]
  706. if name in self.ctrls:
  707. if 'requires' in data:
  708. set = self.getSetting(data['requires'])
  709. for i in self.ctrls[name]:
  710. i.Enable(set not in ["off", "false", '0'])
  711. def getSetting(self, valueName):
  712. search = re.search(r"(?:^|\n)"+valueName + r"\s*:\s*(\w*)\s*(?:\n|$)", self.widget.passage.text, flags=re.I)
  713. if search:
  714. return search.group(1)
  715. return ''
  716. def saveSetting(self, valueName, value):
  717. newEntry = valueName+":"+str(value)+'\n'
  718. sub = re.subn("^"+valueName+r"\s*:\s*[^\n]+\n", newEntry, self.widget.passage.text, flags=re.I|re.M)
  719. if sub[1]:
  720. self.widget.passage.text = sub[0]
  721. else:
  722. self.widget.passage.text += newEntry
  723. self.widget.parent.parent.setDirty(True)
  724. self.widget.clearPaintCache()
  725. self.widget.passage.update()
  726. self.enableCtrls()
  727. class ImageFrame(PassageFrame):
  728. """
  729. A window which only displays passages whose text consists of base64 encoded images -
  730. the image is converted to a bitmap and displayed, if possible.
  731. """
  732. def __init__(self, parent, widget, app):
  733. self.widget = widget
  734. self.app = app
  735. self.syncTimer = None
  736. self.image = None
  737. self.gif = None
  738. wx.Frame.__init__(self, parent, wx.ID_ANY, title = 'Untitled Passage - ' + self.app.NAME + ' ' + versionString, \
  739. size = PassageFrame.DEFAULT_SIZE, style=wx.DEFAULT_FRAME_STYLE)
  740. # controls
  741. self.panel = wx.Panel(self)
  742. allSizer = wx.BoxSizer(wx.VERTICAL)
  743. self.panel.SetSizer(allSizer)
  744. # title control
  745. self.topControls = wx.Panel(self.panel)
  746. topSizer = wx.FlexGridSizer(3, 2, metrics.size('relatedControls'), metrics.size('relatedControls'))
  747. titleLabel = wx.StaticText(self.topControls, style = wx.ALIGN_RIGHT, label = PassageFrame.TITLE_LABEL)
  748. self.titleInput = wx.TextCtrl(self.topControls)
  749. self.titleInput.SetValue(self.widget.passage.title)
  750. self.SetTitle(self.title())
  751. topSizer.Add(titleLabel, 0, flag = wx.ALL, border = metrics.size('focusRing'))
  752. topSizer.Add(self.titleInput, 1, flag = wx.EXPAND | wx.ALL, border = metrics.size('focusRing'))
  753. topSizer.AddGrowableCol(1, 1)
  754. self.topControls.SetSizer(topSizer)
  755. # image pane
  756. self.imageScroller = wx.ScrolledWindow(self.panel)
  757. self.imageSizer = wx.GridSizer(1,1)
  758. self.imageScroller.SetSizer(self.imageSizer)
  759. # image menu
  760. passageMenu = wx.Menu()
  761. passageMenu.Append(self.IMPORT_IMAGE, '&Replace Image...\tCtrl-O')
  762. self.Bind(wx.EVT_MENU, self.replaceImage, id = self.IMPORT_IMAGE)
  763. passageMenu.Append(self.SAVE_IMAGE, '&Save Image...')
  764. self.Bind(wx.EVT_MENU, self.saveImage, id = self.SAVE_IMAGE)
  765. passageMenu.AppendSeparator()
  766. passageMenu.Append(wx.ID_SAVE, '&Save Story\tCtrl-S')
  767. self.Bind(wx.EVT_MENU, self.widget.parent.parent.save, id = wx.ID_SAVE)
  768. passageMenu.Append(PassageFrame.PASSAGE_REBUILD_STORY, '&Rebuild Story\tCtrl-R')
  769. self.Bind(wx.EVT_MENU, self.widget.parent.parent.rebuild, id = PassageFrame.PASSAGE_REBUILD_STORY)
  770. passageMenu.AppendSeparator()
  771. passageMenu.Append(wx.ID_CLOSE, '&Close Image\tCtrl-W')
  772. self.Bind(wx.EVT_MENU, lambda e: self.Destroy(), id = wx.ID_CLOSE)
  773. # edit menu
  774. editMenu = wx.Menu()
  775. editMenu.Append(wx.ID_COPY, '&Copy\tCtrl-C')
  776. self.Bind(wx.EVT_MENU, self.copyImage, id = wx.ID_COPY)
  777. editMenu.Append(wx.ID_PASTE, '&Paste\tCtrl-V')
  778. self.Bind(wx.EVT_MENU, self.pasteImage, id = wx.ID_PASTE)
  779. # menu bar
  780. self.menus = wx.MenuBar()
  781. self.menus.Append(passageMenu, '&Image')
  782. self.menus.Append(editMenu, '&Edit')
  783. self.SetMenuBar(self.menus)
  784. # finish
  785. allSizer.Add(self.topControls, flag = wx.TOP | wx.LEFT | wx.RIGHT | wx.EXPAND, border = metrics.size('windowBorder'))
  786. allSizer.Add(self.imageScroller, proportion = 1, flag = wx.TOP | wx.EXPAND, border = metrics.size('relatedControls'))
  787. # bindings
  788. self.titleInput.Bind(wx.EVT_TEXT, self.syncPassage)
  789. self.SetIcon(self.app.icon)
  790. self.updateImage()
  791. self.Show(True)
  792. def syncPassage(self, event = None):
  793. """Updates the image based on the title input; asks our matching widget to repaint."""
  794. if len(self.titleInput.GetValue()) > 0:
  795. self.widget.passage.title = self.titleInput.GetValue()
  796. else:
  797. self.widget.passage.title = 'Untitled Image'
  798. self.widget.clearPaintCache()
  799. self.SetTitle(self.title())
  800. # immediately mark the story dirty
  801. self.widget.parent.parent.setDirty(True)
  802. # reset redraw timer
  803. def reallySync(self):
  804. self.widget.parent.Refresh()
  805. if self.syncTimer:
  806. self.syncTimer.cancel()
  807. self.syncTimer = threading.Timer(PassageFrame.PARENT_SYNC_DELAY, reallySync, [self], {})
  808. self.syncTimer.start()
  809. def updateImage(self):
  810. """Assigns a bitmap to this frame's StaticBitmap component,
  811. unless it's a GIF, in which case, animate it."""
  812. if self.gif:
  813. self.gif.Stop()
  814. self.imageSizer.Clear(True)
  815. self.gif = None
  816. self.image = None
  817. size = (32,32)
  818. t = self.widget.passage.text
  819. # Get the bitmap (will be used as inactive for GIFs)
  820. bmp = self.widget.bitmap
  821. if bmp:
  822. size = bmp.GetSize()
  823. # GIF animation
  824. if t.startswith("data:image/gif"):
  825. self.gif = wx.animate.AnimationCtrl(self.imageScroller, size = size)
  826. self.imageSizer.Add(self.gif, 1, wx.ALIGN_CENTER)
  827. # Convert the full GIF to an Animation
  828. anim = wx.animate.Animation()
  829. data = base64.b64decode(t[t.index("base64,")+7:])
  830. anim.Load(cStringIO.StringIO(data))
  831. # Load the Animation into the AnimationCtrl
  832. # Crashes OS X..
  833. #self.gif.SetInactiveBitmap(bmp)
  834. self.gif.SetAnimation(anim)
  835. self.gif.Play()
  836. # Static images
  837. else:
  838. self.image = wx.StaticBitmap(self.imageScroller, style = wx.TE_PROCESS_TAB | wx.BORDER_SUNKEN)
  839. self.imageSizer.Add(self.image, 1, wx.ALIGN_CENTER)
  840. self.image.SetBitmap(bmp)
  841. self.SetSize((min(max(size[0], 320),1024),min(max(size[1], 240),768)+64))
  842. self.imageScroller.SetScrollRate(2,2)
  843. self.widget.clearPaintCache()
  844. self.Refresh()
  845. # Update copy menu
  846. copyItem = self.menus.FindItemById(wx.ID_COPY)
  847. copyItem.Enable(not not bmp)
  848. def replaceImage(self, event = None):
  849. """Replace the image with a new file, if possible."""
  850. self.widget.parent.parent.importImageDialog(replace = self.widget)
  851. self.widget.parent.parent.setDirty(True)
  852. self.updateImage()
  853. def saveImage(self, event = None):
  854. """Saves the base64 image as a file."""
  855. t = self.widget.passage.text
  856. # Get the extension
  857. extension = images.getImageType(t)
  858. dialog = wx.FileDialog(self, 'Save Image', os.getcwd(), self.widget.passage.title + extension, \
  859. 'Image File|*' + extension + '|All Files (*.*)|*.*', wx.SAVE | wx.FD_OVERWRITE_PROMPT | wx.FD_CHANGE_DIR)
  860. if dialog.ShowModal() == wx.ID_OK:
  861. try:
  862. path = dialog.GetPath()
  863. dest = open(path, 'wb')
  864. data = base64.b64decode(images.removeURIPrefix(t))
  865. dest.write(data)
  866. dest.close()
  867. except:
  868. self.app.displayError('saving the image')
  869. dialog.Destroy()
  870. def copyImage(self, event = None):
  871. """Copy the bitmap to the clipboard"""
  872. clip = wx.TheClipboard
  873. if self.image and clip.Open():
  874. clip.SetData(wx.BitmapDataObject(self.image.GetBitmap() if not self.gif else self.gif.GetInactiveBitmap()))
  875. clip.Flush()
  876. clip.Close()
  877. def pasteImage(self, event = None):
  878. """Paste from the clipboard, converting to a PNG"""
  879. clip = wx.TheClipboard
  880. bdo = wx.BitmapDataObject()
  881. pasted = False
  882. # Try and read from the clipboard
  883. if clip.Open():
  884. pasted = clip.GetData(bdo)
  885. clip.Close()
  886. if not pasted:
  887. return
  888. # Convert bitmap to PNG
  889. bmp = bdo.GetBitmap()
  890. self.widget.passage.text = images.bitmapToBase64PNG(bmp)
  891. self.widget.updateBitmap()
  892. self.updateImage()
  893. IMPORT_IMAGE = 1004
  894. EXPORT_IMAGE = 1005
  895. SAVE_IMAGE = 1006