storyframe.py 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333
  1. import sys, re, os, urllib, urlparse, pickle, wx, codecs, tempfile, images, version
  2. from wx.lib import imagebrowser
  3. from tiddlywiki import TiddlyWiki
  4. from storypanel import StoryPanel
  5. from passagewidget import PassageWidget
  6. from statisticsdialog import StatisticsDialog
  7. from storysearchframes import StoryFindFrame, StoryReplaceFrame
  8. from storymetadataframe import StoryMetadataFrame
  9. from utils import isURL
  10. class StoryFrame(wx.Frame):
  11. """
  12. A StoryFrame displays an entire story. Its main feature is an
  13. instance of a StoryPanel, but it also has a menu bar and toolbar.
  14. """
  15. def __init__(self, parent, app, state=None, refreshIncludes=True):
  16. wx.Frame.__init__(self, parent, wx.ID_ANY, title=StoryFrame.DEFAULT_TITLE, \
  17. size=StoryFrame.DEFAULT_SIZE)
  18. self.app = app
  19. self.parent = parent
  20. self.pristine = True # the user has not added any content to this at all
  21. self.dirty = False # the user has not made unsaved changes
  22. self.storyFormats = {} # list of available story formats
  23. self.lastTestBuild = None
  24. self.title = ""
  25. # inner state
  26. if state:
  27. self.buildDestination = state.get('buildDestination', '')
  28. self.saveDestination = state.get('saveDestination', '')
  29. self.setTarget(state.get('target', 'sugarcane').lower())
  30. self.metadata = state.get('metadata', {})
  31. self.storyPanel = StoryPanel(self, app, state=state['storyPanel'])
  32. self.pristine = False
  33. else:
  34. self.buildDestination = ''
  35. self.saveDestination = ''
  36. self.metadata = {}
  37. self.setTarget('sugarcane')
  38. self.storyPanel = StoryPanel(self, app)
  39. if refreshIncludes:
  40. self.storyPanel.refreshIncludedPassageList()
  41. # window events
  42. self.Bind(wx.EVT_CLOSE, self.checkClose)
  43. self.Bind(wx.EVT_UPDATE_UI, self.updateUI)
  44. # Timer for the auto build file watcher
  45. self.autobuildtimer = wx.Timer(self)
  46. self.Bind(wx.EVT_TIMER, self.autoBuildTick, self.autobuildtimer)
  47. # File menu
  48. fileMenu = wx.Menu()
  49. fileMenu.Append(wx.ID_NEW, '&New Story\tCtrl-Shift-N')
  50. self.Bind(wx.EVT_MENU, self.app.newStory, id=wx.ID_NEW)
  51. fileMenu.Append(wx.ID_OPEN, '&Open Story...\tCtrl-O')
  52. self.Bind(wx.EVT_MENU, self.app.openDialog, id=wx.ID_OPEN)
  53. recentFilesMenu = wx.Menu()
  54. self.recentFiles = wx.FileHistory(self.app.RECENT_FILES)
  55. self.recentFiles.Load(self.app.config)
  56. self.app.verifyRecentFiles(self)
  57. self.recentFiles.UseMenu(recentFilesMenu)
  58. self.recentFiles.AddFilesToThisMenu(recentFilesMenu)
  59. fileMenu.AppendMenu(wx.ID_ANY, 'Open &Recent', recentFilesMenu)
  60. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 0), id=wx.ID_FILE1)
  61. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 1), id=wx.ID_FILE2)
  62. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 2), id=wx.ID_FILE3)
  63. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 3), id=wx.ID_FILE4)
  64. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 4), id=wx.ID_FILE5)
  65. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 5), id=wx.ID_FILE6)
  66. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 6), id=wx.ID_FILE7)
  67. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 7), id=wx.ID_FILE8)
  68. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 8), id=wx.ID_FILE9)
  69. self.Bind(wx.EVT_MENU, lambda e: self.app.openRecent(self, 9), id=wx.ID_FILE9 + 1)
  70. fileMenu.AppendSeparator()
  71. fileMenu.Append(wx.ID_SAVE, '&Save Story\tCtrl-S')
  72. self.Bind(wx.EVT_MENU, self.save, id=wx.ID_SAVE)
  73. fileMenu.Append(wx.ID_SAVEAS, 'S&ave Story As...\tCtrl-Shift-S')
  74. self.Bind(wx.EVT_MENU, self.saveAs, id=wx.ID_SAVEAS)
  75. fileMenu.Append(wx.ID_REVERT_TO_SAVED, '&Revert to Saved')
  76. self.Bind(wx.EVT_MENU, self.revert, id=wx.ID_REVERT_TO_SAVED)
  77. fileMenu.AppendSeparator()
  78. # Import submenu
  79. importMenu = wx.Menu()
  80. importMenu.Append(StoryFrame.FILE_IMPORT_HTML, 'Compiled &HTML File...')
  81. self.Bind(wx.EVT_MENU, self.importHtmlDialog, id=StoryFrame.FILE_IMPORT_HTML)
  82. importMenu.Append(StoryFrame.FILE_IMPORT_SOURCE, 'Twee Source &Code...')
  83. self.Bind(wx.EVT_MENU, self.importSourceDialog, id=StoryFrame.FILE_IMPORT_SOURCE)
  84. fileMenu.AppendMenu(wx.ID_ANY, '&Import', importMenu)
  85. # Export submenu
  86. exportMenu = wx.Menu()
  87. exportMenu.Append(StoryFrame.FILE_EXPORT_SOURCE, 'Twee Source &Code...')
  88. self.Bind(wx.EVT_MENU, self.exportSource, id=StoryFrame.FILE_EXPORT_SOURCE)
  89. exportMenu.Append(StoryFrame.FILE_EXPORT_PROOF, '&Proofing Copy...')
  90. self.Bind(wx.EVT_MENU, self.proof, id=StoryFrame.FILE_EXPORT_PROOF)
  91. fileMenu.AppendMenu(wx.ID_ANY, '&Export', exportMenu)
  92. fileMenu.AppendSeparator()
  93. fileMenu.Append(wx.ID_CLOSE, '&Close Story\tCtrl-W')
  94. self.Bind(wx.EVT_MENU, self.checkCloseMenu, id=wx.ID_CLOSE)
  95. fileMenu.Append(wx.ID_EXIT, 'E&xit Twine\tCtrl-Q')
  96. self.Bind(wx.EVT_MENU, lambda e: self.app.exit(), id=wx.ID_EXIT)
  97. # Edit menu
  98. editMenu = wx.Menu()
  99. editMenu.Append(wx.ID_UNDO, '&Undo\tCtrl-Z')
  100. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.undo(), id=wx.ID_UNDO)
  101. if sys.platform == 'darwin':
  102. shortcut = 'Ctrl-Shift-Z'
  103. else:
  104. shortcut = 'Ctrl-Y'
  105. editMenu.Append(wx.ID_REDO, '&Redo\t' + shortcut)
  106. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.redo(), id=wx.ID_REDO)
  107. editMenu.AppendSeparator()
  108. editMenu.Append(wx.ID_CUT, 'Cu&t\tCtrl-X')
  109. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.cutWidgets(), id=wx.ID_CUT)
  110. editMenu.Append(wx.ID_COPY, '&Copy\tCtrl-C')
  111. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.copyWidgets(), id=wx.ID_COPY)
  112. editMenu.Append(wx.ID_PASTE, '&Paste\tCtrl-V')
  113. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.pasteWidgets(), id=wx.ID_PASTE)
  114. editMenu.Append(wx.ID_DELETE, '&Delete\tDel')
  115. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.removeWidgets(e, saveUndo=True), id=wx.ID_DELETE)
  116. editMenu.Append(wx.ID_SELECTALL, 'Select &All\tCtrl-A')
  117. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.eachWidget(lambda i: i.setSelected(True, exclusive=False)),
  118. id=wx.ID_SELECTALL)
  119. editMenu.AppendSeparator()
  120. editMenu.Append(wx.ID_FIND, 'Find...\tCtrl-F')
  121. self.Bind(wx.EVT_MENU, self.showFind, id=wx.ID_FIND)
  122. editMenu.Append(StoryFrame.EDIT_FIND_NEXT, 'Find Next\tCtrl-G')
  123. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.findWidgetRegexp(), id=StoryFrame.EDIT_FIND_NEXT)
  124. if sys.platform == 'darwin':
  125. shortcut = 'Ctrl-Shift-H'
  126. else:
  127. shortcut = 'Ctrl-H'
  128. editMenu.Append(wx.ID_REPLACE, 'Replace Across Story...\t' + shortcut)
  129. self.Bind(wx.EVT_MENU, self.showReplace, id=wx.ID_REPLACE)
  130. editMenu.AppendSeparator()
  131. editMenu.Append(wx.ID_PREFERENCES, 'Preferences...\tCtrl-,')
  132. self.Bind(wx.EVT_MENU, self.app.showPrefs, id=wx.ID_PREFERENCES)
  133. # View menu
  134. viewMenu = wx.Menu()
  135. viewMenu.Append(wx.ID_ZOOM_IN, 'Zoom &In\t=')
  136. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.zoom('in'), id=wx.ID_ZOOM_IN)
  137. viewMenu.Append(wx.ID_ZOOM_OUT, 'Zoom &Out\t-')
  138. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.zoom('out'), id=wx.ID_ZOOM_OUT)
  139. viewMenu.Append(wx.ID_ZOOM_FIT, 'Zoom to &Fit\t0')
  140. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.zoom('fit'), id=wx.ID_ZOOM_FIT)
  141. viewMenu.Append(wx.ID_ZOOM_100, 'Zoom &100%\t1')
  142. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.zoom(1), id=wx.ID_ZOOM_100)
  143. viewMenu.AppendSeparator()
  144. viewMenu.Append(StoryFrame.VIEW_SNAP, 'Snap to &Grid', kind=wx.ITEM_CHECK)
  145. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.toggleSnapping(), id=StoryFrame.VIEW_SNAP)
  146. viewMenu.Append(StoryFrame.VIEW_CLEANUP, '&Clean Up Passages')
  147. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.cleanup(), id=StoryFrame.VIEW_CLEANUP)
  148. viewMenu.AppendSeparator()
  149. viewMenu.Append(StoryFrame.VIEW_TOOLBAR, '&Toolbar', kind=wx.ITEM_CHECK)
  150. self.Bind(wx.EVT_MENU, self.toggleToolbar, id=StoryFrame.VIEW_TOOLBAR)
  151. # Story menu
  152. self.storyMenu = wx.Menu()
  153. # New Passage submenu
  154. self.newPassageMenu = wx.Menu()
  155. self.newPassageMenu.Append(StoryFrame.STORY_NEW_PASSAGE, '&Passage\tCtrl-N')
  156. self.Bind(wx.EVT_MENU, self.storyPanel.newWidget, id=StoryFrame.STORY_NEW_PASSAGE)
  157. self.newPassageMenu.AppendSeparator()
  158. self.newPassageMenu.Append(StoryFrame.STORY_NEW_STYLESHEET, 'S&tylesheet')
  159. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.newWidget(text=self.storyPanel.FIRST_CSS, \
  160. tags=['stylesheet']),
  161. id=StoryFrame.STORY_NEW_STYLESHEET)
  162. self.newPassageMenu.Append(StoryFrame.STORY_NEW_SCRIPT, '&Script')
  163. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.newWidget(tags=['script']), id=StoryFrame.STORY_NEW_SCRIPT)
  164. self.newPassageMenu.Append(StoryFrame.STORY_NEW_ANNOTATION, '&Annotation')
  165. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.newWidget(tags=['annotation']),
  166. id=StoryFrame.STORY_NEW_ANNOTATION)
  167. self.storyMenu.AppendMenu(wx.ID_ANY, 'New', self.newPassageMenu)
  168. self.storyMenu.Append(wx.ID_EDIT, '&Edit Passage\tCtrl-E')
  169. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.eachSelectedWidget(lambda w: w.openEditor(e)), id=wx.ID_EDIT)
  170. self.storyMenu.Append(StoryFrame.STORY_EDIT_FULLSCREEN, 'Edit in &Fullscreen\tF12')
  171. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.eachSelectedWidget(lambda w: w.openEditor(e, fullscreen=True)), \
  172. id=StoryFrame.STORY_EDIT_FULLSCREEN)
  173. self.storyMenu.AppendSeparator()
  174. self.importImageMenu = wx.Menu()
  175. self.importImageMenu.Append(StoryFrame.STORY_IMPORT_IMAGE, 'From &File...')
  176. self.Bind(wx.EVT_MENU, self.importImageDialog, id=StoryFrame.STORY_IMPORT_IMAGE)
  177. self.importImageMenu.Append(StoryFrame.STORY_IMPORT_IMAGE_URL, 'From Web &URL...')
  178. self.Bind(wx.EVT_MENU, self.importImageURLDialog, id=StoryFrame.STORY_IMPORT_IMAGE_URL)
  179. self.storyMenu.AppendMenu(wx.ID_ANY, 'Import &Image', self.importImageMenu)
  180. self.storyMenu.Append(StoryFrame.STORY_IMPORT_FONT, 'Import &Font...')
  181. self.Bind(wx.EVT_MENU, self.importFontDialog, id=StoryFrame.STORY_IMPORT_FONT)
  182. self.storyMenu.AppendSeparator()
  183. # Story Settings submenu
  184. self.storySettingsMenu = wx.Menu()
  185. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_START, 'Start')
  186. self.Bind(wx.EVT_MENU, self.createInfoPassage, id=StoryFrame.STORYSETTINGS_START)
  187. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_TITLE, 'StoryTitle')
  188. self.Bind(wx.EVT_MENU, self.createInfoPassage, id=StoryFrame.STORYSETTINGS_TITLE)
  189. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_SUBTITLE, 'StorySubtitle')
  190. self.Bind(wx.EVT_MENU, self.createInfoPassage, id=StoryFrame.STORYSETTINGS_SUBTITLE)
  191. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_AUTHOR, 'StoryAuthor')
  192. self.Bind(wx.EVT_MENU, self.createInfoPassage, id=StoryFrame.STORYSETTINGS_AUTHOR)
  193. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_MENU, 'StoryMenu')
  194. self.Bind(wx.EVT_MENU, self.createInfoPassage, id=StoryFrame.STORYSETTINGS_MENU)
  195. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_INIT, 'StoryInit')
  196. self.Bind(wx.EVT_MENU, self.createInfoPassage, id=StoryFrame.STORYSETTINGS_INIT)
  197. # Separator for 'visible' passages (title, subtitle) and those that solely affect compilation
  198. self.storySettingsMenu.AppendSeparator()
  199. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_SETTINGS, 'StorySettings')
  200. self.Bind(wx.EVT_MENU, self.createInfoPassage, id=StoryFrame.STORYSETTINGS_SETTINGS)
  201. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_INCLUDES, 'StoryIncludes')
  202. self.Bind(wx.EVT_MENU, self.createInfoPassage, id=StoryFrame.STORYSETTINGS_INCLUDES)
  203. self.storySettingsMenu.AppendSeparator()
  204. self.storySettingsMenu.Append(StoryFrame.STORYSETTINGS_HELP, 'About Special Passages')
  205. self.Bind(wx.EVT_MENU, lambda e: wx.LaunchDefaultBrowser('http://twinery.org/wiki/special_passages'),
  206. id=StoryFrame.STORYSETTINGS_HELP)
  207. self.storyMenu.AppendMenu(wx.ID_ANY, 'Special Passages', self.storySettingsMenu)
  208. self.storyMenu.AppendSeparator()
  209. self.storyMenu.Append(StoryFrame.REFRESH_INCLUDES_LINKS, 'Update StoryIncludes Links')
  210. self.Bind(wx.EVT_MENU, lambda e: self.storyPanel.refreshIncludedPassageList(),
  211. id=StoryFrame.REFRESH_INCLUDES_LINKS)
  212. self.storyMenu.AppendSeparator()
  213. # Story Format submenu
  214. storyFormatMenu = wx.Menu()
  215. storyFormatCounter = StoryFrame.STORY_FORMAT_BASE
  216. for key in sorted(app.headers.keys()):
  217. header = app.headers[key]
  218. storyFormatMenu.Append(storyFormatCounter, header.label, kind=wx.ITEM_CHECK)
  219. self.Bind(wx.EVT_MENU, lambda e, target=key: self.setTarget(target), id=storyFormatCounter)
  220. self.storyFormats[storyFormatCounter] = header
  221. storyFormatCounter += 1
  222. if storyFormatCounter:
  223. storyFormatMenu.AppendSeparator()
  224. storyFormatMenu.Append(StoryFrame.STORY_FORMAT_HELP, '&About Story Formats')
  225. self.Bind(wx.EVT_MENU, lambda e: self.app.storyFormatHelp(), id=StoryFrame.STORY_FORMAT_HELP)
  226. self.storyMenu.AppendMenu(wx.ID_ANY, 'Story &Format', storyFormatMenu)
  227. self.storyMenu.Append(StoryFrame.STORY_METADATA, 'Story &Metadata...')
  228. self.Bind(wx.EVT_MENU, self.showMetadata, id=StoryFrame.STORY_METADATA)
  229. self.storyMenu.Append(StoryFrame.STORY_STATS, 'Story &Statistics\tCtrl-I')
  230. self.Bind(wx.EVT_MENU, self.stats, id=StoryFrame.STORY_STATS)
  231. # Build menu
  232. buildMenu = wx.Menu()
  233. buildMenu.Append(StoryFrame.BUILD_TEST, '&Test Play\tCtrl-T')
  234. self.Bind(wx.EVT_MENU, self.testBuild, id=StoryFrame.BUILD_TEST)
  235. buildMenu.Append(StoryFrame.BUILD_TEST_HERE, 'Test Play From Here\tCtrl-Shift-T')
  236. self.Bind(wx.EVT_MENU,
  237. lambda e: self.storyPanel.eachSelectedWidget(lambda w: self.testBuild(startAt=w.passage.title)), \
  238. id=StoryFrame.BUILD_TEST_HERE)
  239. buildMenu.Append(StoryFrame.BUILD_VERIFY, '&Verify All Passages')
  240. self.Bind(wx.EVT_MENU, self.verify, id=StoryFrame.BUILD_VERIFY)
  241. buildMenu.AppendSeparator()
  242. buildMenu.Append(StoryFrame.BUILD_BUILD, '&Build Story...\tCtrl-B')
  243. self.Bind(wx.EVT_MENU, self.build, id=StoryFrame.BUILD_BUILD)
  244. buildMenu.Append(StoryFrame.BUILD_REBUILD, '&Rebuild Story\tCtrl-R')
  245. self.Bind(wx.EVT_MENU, self.rebuild, id=StoryFrame.BUILD_REBUILD)
  246. buildMenu.Append(StoryFrame.BUILD_VIEW_LAST, '&Rebuild and View\tCtrl-L')
  247. self.Bind(wx.EVT_MENU, lambda e: self.rebuild(displayAfter=True), id=StoryFrame.BUILD_VIEW_LAST)
  248. buildMenu.AppendSeparator()
  249. self.autobuildmenuitem = buildMenu.Append(StoryFrame.BUILD_AUTO_BUILD, '&Auto Build', kind=wx.ITEM_CHECK)
  250. self.Bind(wx.EVT_MENU, self.autoBuild, self.autobuildmenuitem)
  251. buildMenu.Check(StoryFrame.BUILD_AUTO_BUILD, False)
  252. # Help menu
  253. helpMenu = wx.Menu()
  254. helpMenu.Append(StoryFrame.HELP_MANUAL, 'Twine &Wiki')
  255. self.Bind(wx.EVT_MENU, self.app.openDocs, id=StoryFrame.HELP_MANUAL)
  256. helpMenu.Append(StoryFrame.HELP_FORUM, 'Twine &Forum')
  257. self.Bind(wx.EVT_MENU, self.app.openForum, id=StoryFrame.HELP_FORUM)
  258. helpMenu.Append(StoryFrame.HELP_GITHUB, 'Twine\'s Source Code on &GitHub')
  259. self.Bind(wx.EVT_MENU, self.app.openGitHub, id=StoryFrame.HELP_GITHUB)
  260. helpMenu.AppendSeparator()
  261. helpMenu.Append(wx.ID_ABOUT, '&About Twine')
  262. self.Bind(wx.EVT_MENU, self.app.about, id=wx.ID_ABOUT)
  263. # add menus
  264. self.menus = wx.MenuBar()
  265. self.menus.Append(fileMenu, '&File')
  266. self.menus.Append(editMenu, '&Edit')
  267. self.menus.Append(viewMenu, '&View')
  268. self.menus.Append(self.storyMenu, '&Story')
  269. self.menus.Append(buildMenu, '&Build')
  270. self.menus.Append(helpMenu, '&Help')
  271. self.SetMenuBar(self.menus)
  272. # enable/disable paste menu option depending on clipboard contents
  273. self.clipboardMonitor = ClipboardMonitor(self.menus.FindItemById(wx.ID_PASTE).Enable)
  274. self.clipboardMonitor.Start(100)
  275. # extra shortcuts
  276. self.SetAcceleratorTable(wx.AcceleratorTable([ \
  277. (wx.ACCEL_NORMAL, wx.WXK_RETURN, wx.ID_EDIT), \
  278. (wx.ACCEL_CTRL, wx.WXK_RETURN, StoryFrame.STORY_EDIT_FULLSCREEN) \
  279. ]))
  280. iconPath = self.app.iconsPath
  281. self.toolbar = self.CreateToolBar(style=wx.TB_FLAT | wx.TB_NODIVIDER)
  282. self.toolbar.SetToolBitmapSize((StoryFrame.TOOLBAR_ICON_SIZE, StoryFrame.TOOLBAR_ICON_SIZE))
  283. self.toolbar.AddLabelTool(StoryFrame.STORY_NEW_PASSAGE, 'New Passage', \
  284. wx.Bitmap(iconPath + 'newpassage.png'), \
  285. shortHelp=StoryFrame.NEW_PASSAGE_TOOLTIP)
  286. self.Bind(wx.EVT_TOOL, lambda e: self.storyPanel.newWidget(), id=StoryFrame.STORY_NEW_PASSAGE)
  287. self.toolbar.AddSeparator()
  288. self.toolbar.AddLabelTool(wx.ID_ZOOM_IN, 'Zoom In', \
  289. wx.Bitmap(iconPath + 'zoomin.png'), \
  290. shortHelp=StoryFrame.ZOOM_IN_TOOLTIP)
  291. self.Bind(wx.EVT_TOOL, lambda e: self.storyPanel.zoom('in'), id=wx.ID_ZOOM_IN)
  292. self.toolbar.AddLabelTool(wx.ID_ZOOM_OUT, 'Zoom Out', \
  293. wx.Bitmap(iconPath + 'zoomout.png'), \
  294. shortHelp=StoryFrame.ZOOM_OUT_TOOLTIP)
  295. self.Bind(wx.EVT_TOOL, lambda e: self.storyPanel.zoom('out'), id=wx.ID_ZOOM_OUT)
  296. self.toolbar.AddLabelTool(wx.ID_ZOOM_FIT, 'Zoom to Fit', \
  297. wx.Bitmap(iconPath + 'zoomfit.png'), \
  298. shortHelp=StoryFrame.ZOOM_FIT_TOOLTIP)
  299. self.Bind(wx.EVT_TOOL, lambda e: self.storyPanel.zoom('fit'), id=wx.ID_ZOOM_FIT)
  300. self.toolbar.AddLabelTool(wx.ID_ZOOM_100, 'Zoom to 100%', \
  301. wx.Bitmap(iconPath + 'zoom1.png'), \
  302. shortHelp=StoryFrame.ZOOM_ONE_TOOLTIP)
  303. self.Bind(wx.EVT_TOOL, lambda e: self.storyPanel.zoom(1.0), id=wx.ID_ZOOM_100)
  304. self.SetIcon(self.app.icon)
  305. if app.config.ReadBool('storyFrameToolbar'):
  306. self.showToolbar = True
  307. self.toolbar.Realize()
  308. else:
  309. self.showToolbar = False
  310. self.toolbar.Realize()
  311. self.toolbar.Hide()
  312. def revert(self, event=None):
  313. """Reverts to the last saved version of the story file."""
  314. bits = os.path.splitext(self.saveDestination)
  315. title = '"' + os.path.basename(bits[0]) + '"'
  316. if title == '""': title = 'your story'
  317. message = 'Revert to the last saved version of ' + title + '?'
  318. dialog = wx.MessageDialog(self, message, 'Revert to Saved', wx.ICON_WARNING | wx.YES_NO | wx.NO_DEFAULT)
  319. if dialog.ShowModal() == wx.ID_YES:
  320. self.Destroy()
  321. self.app.open(self.saveDestination)
  322. self.dirty = False
  323. self.checkClose(None)
  324. def checkClose(self, event):
  325. self.checkCloseDo(event, byMenu=False)
  326. def checkCloseMenu(self, event):
  327. self.checkCloseDo(event, byMenu=True)
  328. def checkCloseDo(self, event, byMenu):
  329. """
  330. If this instance's dirty flag is set, asks the user if they want to save the changes.
  331. """
  332. if self.dirty:
  333. bits = os.path.splitext(self.saveDestination)
  334. title = '"' + os.path.basename(bits[0]) + '"'
  335. if title == '""': title = 'your story'
  336. message = 'Do you want to save the changes to ' + title + ' before closing?'
  337. dialog = wx.MessageDialog(self, message, 'Unsaved Changes', \
  338. wx.ICON_WARNING | wx.YES_NO | wx.CANCEL | wx.YES_DEFAULT)
  339. result = dialog.ShowModal()
  340. if result == wx.ID_CANCEL:
  341. event.Veto()
  342. return
  343. elif result == wx.ID_NO:
  344. self.dirty = False
  345. else:
  346. self.save(None)
  347. if self.dirty:
  348. event.Veto()
  349. return
  350. # ask all our widgets to close any editor windows
  351. for w in list(self.storyPanel.widgetDict.itervalues()):
  352. if isinstance(w, PassageWidget):
  353. w.closeEditor()
  354. if self.lastTestBuild and os.path.exists(self.lastTestBuild.name):
  355. try:
  356. os.remove(self.lastTestBuild.name)
  357. except OSError, ex:
  358. print >> sys.stderr, 'Failed to remove lastest test build:', ex
  359. self.lastTestBuild = None
  360. self.app.removeStory(self, byMenu)
  361. if event is not None:
  362. event.Skip()
  363. self.Destroy()
  364. def saveAs(self, event=None):
  365. """Asks the user to choose a file to save state to, then passes off control to save()."""
  366. dialog = wx.FileDialog(self, 'Save Story As', os.getcwd(), "", \
  367. "Twine Story (*.tws)|*.tws|Twine Story without private content [copy] (*.tws)|*.tws", \
  368. wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT | wx.FD_CHANGE_DIR)
  369. if dialog.ShowModal() == wx.ID_OK:
  370. if dialog.GetFilterIndex() == 0:
  371. self.saveDestination = dialog.GetPath()
  372. self.app.config.Write('savePath', os.getcwd())
  373. self.app.addRecentFile(self.saveDestination)
  374. self.save(None)
  375. elif dialog.GetFilterIndex() == 1:
  376. npsavedestination = dialog.GetPath()
  377. try:
  378. dest = open(npsavedestination, 'wb')
  379. pickle.dump(self.serialize_noprivate(npsavedestination), dest)
  380. dest.close()
  381. self.app.addRecentFile(npsavedestination)
  382. except:
  383. self.app.displayError('saving your story')
  384. dialog.Destroy()
  385. def exportSource(self, event=None):
  386. """Asks the user to choose a file to export source to, then exports the wiki."""
  387. dialog = wx.FileDialog(self, 'Export Source Code', os.getcwd(), "", \
  388. 'Twee File (*.twee;* .tw; *.txt)|*.twee;*.tw;*.txt|All Files (*.*)|*.*',
  389. wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT | wx.FD_CHANGE_DIR)
  390. if dialog.ShowModal() == wx.ID_OK:
  391. try:
  392. path = dialog.GetPath()
  393. tw = TiddlyWiki()
  394. for widget in self.storyPanel.widgetDict.itervalues(): tw.addTiddler(widget.passage)
  395. dest = codecs.open(path, 'w', 'utf-8-sig', 'replace')
  396. order = [widget.passage.title for widget in self.storyPanel.sortedWidgets()]
  397. dest.write(tw.toTwee(order))
  398. dest.close()
  399. except:
  400. self.app.displayError('exporting your source code')
  401. dialog.Destroy()
  402. def importHtmlDialog(self, event=None):
  403. """Asks the user to choose a file to import HTML tiddlers from, then imports into the current story."""
  404. dialog = wx.FileDialog(self, 'Import From Compiled HTML', os.getcwd(), '', \
  405. 'HTML Twine game (*.html;* .htm; *.txt)|*.html;*.htm;*.txt|All Files (*.*)|*.*',
  406. wx.FD_OPEN | wx.FD_CHANGE_DIR)
  407. if dialog.ShowModal() == wx.ID_OK:
  408. self.importHtml(dialog.GetPath())
  409. def importHtml(self, path):
  410. """Imports the tiddler objects in a HTML file into the story."""
  411. self.importSource(path, True)
  412. def importSourceDialog(self, event=None):
  413. """Asks the user to choose a file to import source from, then imports into the current story."""
  414. dialog = wx.FileDialog(self, 'Import Source Code', os.getcwd(), '', \
  415. 'Twee File (*.twee;* .tw; *.txt)|*.twee;*.tw;*.txt|All Files (*.*)|*.*',
  416. wx.FD_OPEN | wx.FD_CHANGE_DIR)
  417. if dialog.ShowModal() == wx.ID_OK:
  418. self.importSource(dialog.GetPath())
  419. def importSource(self, path, html=False):
  420. """Imports the tiddler objects in a Twee file into the story."""
  421. try:
  422. # have a TiddlyWiki object parse it for us
  423. tw = TiddlyWiki()
  424. if html:
  425. tw.addHtmlFromFilename(path)
  426. else:
  427. tw.addTweeFromFilename(path)
  428. # add passages for each of the tiddlers the TiddlyWiki saw
  429. if len(tw.tiddlers):
  430. removedWidgets = []
  431. skippedTitles = set()
  432. # Ask user how to resolve any passage title conflicts
  433. for title in tw.tiddlers.viewkeys() & self.storyPanel.widgetDict.viewkeys():
  434. dialog = wx.MessageDialog(self, 'There is already a passage titled "' + title \
  435. + '" in this story. Replace it with the imported passage?',
  436. 'Passage Title Conflict', \
  437. wx.ICON_WARNING | wx.YES_NO | wx.CANCEL | wx.YES_DEFAULT)
  438. check = dialog.ShowModal()
  439. if check == wx.ID_YES:
  440. removedWidgets.append(title)
  441. elif check == wx.ID_CANCEL:
  442. return
  443. elif check == wx.ID_NO:
  444. skippedTitles.add(title)
  445. # Remove widgets elected to be replaced
  446. for title in removedWidgets:
  447. self.storyPanel.removeWidget(title)
  448. # Insert widgets now
  449. lastpos = [0, 0]
  450. addedWidgets = []
  451. for tiddler in tw.tiddlers.itervalues():
  452. if tiddler.title in skippedTitles:
  453. continue
  454. new = self.storyPanel.newWidget(title=tiddler.title, tags=tiddler.tags,
  455. text=tiddler.text, quietly=True,
  456. pos=tiddler.pos if tiddler.pos else lastpos)
  457. lastpos = new.pos
  458. addedWidgets.append(new)
  459. self.setDirty(True, 'Import')
  460. for widget in addedWidgets:
  461. widget.clearPaintCache()
  462. else:
  463. if html:
  464. what = "compiled HTML"
  465. else:
  466. what = "Twee source"
  467. dialog = wx.MessageDialog(self, 'No passages were found in this file. Make sure ' + \
  468. 'this is a ' + what + ' file.', 'No Passages Found', \
  469. wx.ICON_INFORMATION | wx.OK)
  470. dialog.ShowModal()
  471. except:
  472. self.app.displayError('importing')
  473. def importImageURL(self, url, showdialog=True):
  474. """
  475. Downloads the image file from the url and creates a passage.
  476. Returns the resulting passage name, or None
  477. """
  478. try:
  479. # Download the file
  480. urlfile = urllib.urlopen(url)
  481. path = urlparse.urlsplit(url)[2]
  482. title = os.path.splitext(os.path.basename(path))[0]
  483. file = urlfile.read().encode('base64').replace('\n', '')
  484. # Now that the file's read, check the info
  485. maintype = urlfile.info().getmaintype()
  486. if maintype != "image":
  487. self.app.displayError("importing from the web: The server served " + maintype + " instead of an image",
  488. stacktrace=False)
  489. return None
  490. # Convert the file
  491. mimeType = urlfile.info().gettype()
  492. urlfile.close()
  493. text = "data:" + mimeType + ";base64," + file
  494. return self.finishImportImage(text, title, showdialog=showdialog)
  495. except:
  496. self.app.displayError('importing from the web')
  497. return None
  498. def importImageURLDialog(self, event=None):
  499. dialog = wx.TextEntryDialog(self, "Enter the image URL (GIFs, JPEGs, PNGs, SVGs and WebPs only)",
  500. "Import Image from Web", "http://")
  501. if dialog.ShowModal() == wx.ID_OK:
  502. self.importImageURL(dialog.GetValue())
  503. def importImageFile(self, file, replace=None, showdialog=True):
  504. """
  505. Perform the file I/O to import an image file, then add it as an image passage.
  506. Returns the name of the resulting passage, or None
  507. """
  508. try:
  509. if not replace:
  510. text, title = self.openFileAsBase64(file)
  511. return self.finishImportImage(text, title, showdialog=showdialog)
  512. else:
  513. replace.passage.text = self.openFileAsBase64(file)[0]
  514. replace.updateBitmap()
  515. return replace.passage.title
  516. except IOError:
  517. self.app.displayError('importing an image')
  518. return None
  519. def importImageDialog(self, event=None, useImageDialog=False, replace=None):
  520. """Asks the user to choose an image file to import, then imports into the current story.
  521. replace is a Tiddler, if any, that will be replaced by the image."""
  522. # Use the wxPython image browser?
  523. if useImageDialog:
  524. dialog = imagebrowser.ImageDialog(self, os.getcwd())
  525. dialog.ChangeFileTypes([('Web Image File', '*.(gif|jpg|jpeg|png|webp|svg)')])
  526. dialog.ResetFiles()
  527. else:
  528. dialog = wx.FileDialog(self, 'Import Image File', os.getcwd(), '', \
  529. 'Web Image File|*.gif;*.jpg;*.jpeg;*.png;*.webp;*.svg|All Files (*.*)|*.*',
  530. wx.FD_OPEN | wx.FD_CHANGE_DIR)
  531. if dialog.ShowModal() == wx.ID_OK:
  532. file = dialog.GetFile() if useImageDialog else dialog.GetPath()
  533. self.importImageFile(file, replace)
  534. def importFontDialog(self, event=None):
  535. """Asks the user to choose a font file to import, then imports into the current story."""
  536. dialog = wx.FileDialog(self, 'Import Font File', os.getcwd(), '', \
  537. 'Web Font File (.ttf, .otf, .woff, .svg)|*.ttf;*.otf;*.woff;*.svg|All Files (*.*)|*.*',
  538. wx.FD_OPEN | wx.FD_CHANGE_DIR)
  539. if dialog.ShowModal() == wx.ID_OK:
  540. self.importFont(dialog.GetPath())
  541. def openFileAsBase64(self, file):
  542. """Opens a file and returns its base64 representation, expressed as a Data URI with MIME type"""
  543. file64 = open(file, 'rb').read().encode('base64').replace('\n', '')
  544. title, mimeType = os.path.splitext(os.path.basename(file))
  545. return (images.addURIPrefix(file64, mimeType[1:]), title)
  546. def newTitle(self, title):
  547. """ Check if a title is being used, and increment its number if it is."""
  548. while self.storyPanel.passageExists(title):
  549. try:
  550. match = re.search(r'(\s\d+)$', title)
  551. if match:
  552. title = title[:match.start(1)] + " " + str(int(match.group(1)) + 1)
  553. else:
  554. title += " 2"
  555. except:
  556. pass
  557. return title
  558. def finishImportImage(self, text, title, showdialog=True):
  559. """Imports an image into the story as an image passage."""
  560. # Check for title usage
  561. title = self.newTitle(title)
  562. self.storyPanel.newWidget(text=text, title=title, tags=['Twine.image'])
  563. if showdialog:
  564. dialog = wx.MessageDialog(self, 'Image file imported successfully.\n' + \
  565. 'You can include the image in your passages with this syntax:\n\n' + \
  566. '[img[' + title + ']]', 'Image added', \
  567. wx.ICON_INFORMATION | wx.OK)
  568. dialog.ShowModal()
  569. return title
  570. def importFont(self, file, showdialog=True):
  571. """Imports a font into the story as a font passage."""
  572. try:
  573. text, title = self.openFileAsBase64(file)
  574. title2 = self.newTitle(title)
  575. # Wrap in CSS @font-face declaration
  576. text = \
  577. """font[face=\"""" + title + """\"] {
  578. font-family: \"""" + title + """\";
  579. }
  580. @font-face {
  581. font-family: \"""" + title + """\";
  582. src: url(""" + text + """);
  583. }"""
  584. self.storyPanel.newWidget(text=text, title=title2, tags=['stylesheet'])
  585. if showdialog:
  586. dialog = wx.MessageDialog(self, 'Font file imported successfully.\n' + \
  587. 'You can use the font in your stylesheets with this CSS attribute syntax:\n\n' + \
  588. 'font-family: ' + title + ";", 'Font added', \
  589. wx.ICON_INFORMATION | wx.OK)
  590. dialog.ShowModal()
  591. return True
  592. except IOError:
  593. self.app.displayError('importing a font')
  594. return False
  595. def defaultTextForPassage(self, title):
  596. if title == 'Start':
  597. return "Your story will display this passage first. Edit it by double clicking it."
  598. elif title == 'StoryTitle':
  599. return self.DEFAULT_TITLE
  600. elif title == 'StorySubtitle':
  601. return "This text appears below the story's title."
  602. elif title == 'StoryAuthor':
  603. return "Anonymous"
  604. elif title == 'StoryMenu':
  605. return "This passage's text will be included in the menu for this story."
  606. elif title == 'StoryInit':
  607. return """/% Place your story's setup code in this passage.
  608. Any macros in this passage will be run before the Start passage (or any passage you wish to Test Play) is run. %/
  609. """
  610. elif title == 'StoryIncludes':
  611. return """List the file paths of any .twee or .tws files that should be merged into this story when it's built.
  612. You can also include URLs of .tws and .twee files, too.
  613. """
  614. else:
  615. return ""
  616. def createInfoPassage(self, event):
  617. """Open an editor for a special passage; create it if it doesn't exist yet."""
  618. id = event.GetId()
  619. title = self.storySettingsMenu.FindItemById(id).GetLabel()
  620. # What to do about StoryIncludes files?
  621. editingWidget = self.storyPanel.findWidget(title)
  622. if editingWidget is None:
  623. editingWidget = self.storyPanel.newWidget(title=title, text=self.defaultTextForPassage(title))
  624. editingWidget.openEditor()
  625. def save(self, event=None):
  626. if self.saveDestination == '':
  627. self.saveAs()
  628. return
  629. try:
  630. dest = open(self.saveDestination, 'wb')
  631. pickle.dump(self.serialize(), dest)
  632. dest.close()
  633. self.setDirty(False)
  634. self.app.config.Write('LastFile', self.saveDestination)
  635. except:
  636. self.app.displayError('saving your story')
  637. def verify(self, event=None):
  638. """Runs the syntax checks on all passages."""
  639. noprobs = True
  640. for widget in self.storyPanel.widgetDict.itervalues():
  641. result = widget.verifyPassage(self)
  642. if result == -1:
  643. break
  644. elif result > 0:
  645. noprobs = False
  646. if noprobs:
  647. wx.MessageDialog(self, "No obvious problems found in " + str(
  648. len(self.storyPanel.widgetDict)) + " passage" + (
  649. "s." if len(self.storyPanel.widgetDict) > 1 else ".") \
  650. + "\n\n(There may still be problems when the story is played, of course.)",
  651. "Verify All Passages", wx.ICON_INFORMATION).ShowModal()
  652. def build(self, event=None):
  653. """Asks the user to choose a location to save a compiled story, then passed control to rebuild()."""
  654. path, filename = os.path.split(self.buildDestination)
  655. dialog = wx.FileDialog(self, 'Build Story', path or os.getcwd(), filename, \
  656. "Web Page (*.html)|*.html", \
  657. wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT | wx.FD_CHANGE_DIR)
  658. if dialog.ShowModal() == wx.ID_OK:
  659. self.buildDestination = dialog.GetPath()
  660. self.rebuild(None, displayAfter=True)
  661. dialog.Destroy()
  662. def testBuild(self, event=None, startAt=''):
  663. self.rebuild(temp=True, startAt=startAt, displayAfter=True)
  664. def rebuild(self, event=None, temp=False, displayAfter=False, startAt=''):
  665. """
  666. Builds an HTML version of the story. Pass whether to use a temp file, and/or open the file afterwards.
  667. """
  668. try:
  669. # assemble our tiddlywiki and write it out
  670. hasstartpassage = False
  671. tw = TiddlyWiki()
  672. for widget in self.storyPanel.widgetDict.itervalues():
  673. if widget.passage.title == 'StoryIncludes':
  674. def callback(passage, tw=tw):
  675. if passage.title == 'StoryIncludes':
  676. return
  677. # Check for uniqueness
  678. elif passage.title in self.storyPanel.widgetDict:
  679. # Not bothering with a Yes/No dialog here.
  680. raise Exception('A passage titled "' + passage.title + '" is already present in this story')
  681. elif tw.hasTiddler(passage.title):
  682. raise Exception(
  683. 'A passage titled "' + passage.title + '" has been included by a previous StoryIncludes file')
  684. tw.addTiddler(passage)
  685. self.storyPanel.addIncludedPassage(passage.title)
  686. self.readIncludes(widget.passage.text.splitlines(), callback)
  687. # Might as well suppress the warning for a StoryIncludes file
  688. hasstartpassage = True
  689. elif TiddlyWiki.NOINCLUDE_TAGS.isdisjoint(widget.passage.tags):
  690. widget.passage.pos = widget.pos
  691. tw.addTiddler(widget.passage)
  692. if widget.passage.title == "Start":
  693. hasstartpassage = True
  694. # is there a Start passage?
  695. if hasstartpassage == False:
  696. self.app.displayError('building your story because there is no "Start" passage. ' + "\n"
  697. + 'Your story will build but the web browser will not be able to run the story. ' + "\n"
  698. + 'Please add a passage with the title "Start"')
  699. widget = self.storyPanel.widgetDict.get('StorySettings')
  700. if widget is not None:
  701. lines = widget.passage.text.splitlines()
  702. for line in lines:
  703. if ':' in line:
  704. (skey, svalue) = line.split(':')
  705. skey = skey.strip().lower()
  706. svalue = svalue.strip()
  707. tw.storysettings[skey] = svalue
  708. # Write the output file
  709. header = self.app.headers.get(self.target)
  710. metadata = self.metadata
  711. if temp:
  712. # This implicitly closes the previous test build
  713. if self.lastTestBuild and os.path.exists(self.lastTestBuild.name):
  714. os.remove(self.lastTestBuild.name)
  715. path = (os.path.exists(self.buildDestination) and self.buildDestination) \
  716. or (os.path.exists(self.saveDestination) and self.saveDestination) or None
  717. html = tw.toHtml(self.app, header, startAt=startAt, defaultName=self.title, metadata=metadata)
  718. if html:
  719. self.lastTestBuild = tempfile.NamedTemporaryFile(mode='wb', suffix=".html", delete=False,
  720. dir=(path and os.path.dirname(path)) or None)
  721. self.lastTestBuild.write(html.encode('utf-8-sig'))
  722. self.lastTestBuild.close()
  723. if displayAfter: self.viewBuild(name=self.lastTestBuild.name)
  724. else:
  725. dest = open(self.buildDestination, 'wb')
  726. dest.write(tw.toHtml(self.app, header, defaultName=self.title, metadata=metadata).encode('utf-8-sig'))
  727. dest.close()
  728. if displayAfter: self.viewBuild()
  729. except:
  730. self.app.displayError('building your story')
  731. def getLocalDir(self):
  732. dir = (self.saveDestination != '' and os.path.dirname(self.saveDestination)) or None
  733. if not (dir and os.path.isdir(dir)):
  734. dir = os.getcwd()
  735. return dir
  736. def readIncludes(self, lines, callback, silent=False):
  737. """
  738. Examines all of the source files included via StoryIncludes, and performs a callback on each passage found.
  739. callback is a function that takes 1 Tiddler object.
  740. """
  741. twinedocdir = self.getLocalDir()
  742. excludetags = TiddlyWiki.NOINCLUDE_TAGS
  743. self.storyPanel.clearIncludedPassages()
  744. for line in lines:
  745. try:
  746. if line.strip():
  747. extension = os.path.splitext(line)[1]
  748. if extension not in ['.tws', '.tw', '.txt', '.twee']:
  749. raise Exception('File format not recognized')
  750. if isURL(line):
  751. openedFile = urllib.urlopen(line)
  752. else:
  753. openedFile = open(os.path.join(twinedocdir, line), 'r')
  754. if extension == '.tws':
  755. s = StoryFrame(None, app=self.app, state=pickle.load(openedFile), refreshIncludes=False)
  756. openedFile.close()
  757. for widget in s.storyPanel.widgetDict.itervalues():
  758. if excludetags.isdisjoint(widget.passage.tags):
  759. callback(widget.passage)
  760. s.Destroy()
  761. else:
  762. s = openedFile.read()
  763. openedFile.close()
  764. tw1 = TiddlyWiki()
  765. tw1.addTwee(s)
  766. tiddlerkeys = tw1.tiddlers.keys()
  767. for tiddlerkey in tiddlerkeys:
  768. passage = tw1.tiddlers[tiddlerkey]
  769. if excludetags.isdisjoint(passage.tags):
  770. callback(passage)
  771. except:
  772. if not silent:
  773. self.app.displayError(
  774. 'reading the file named "' + line + '" which is referred to by the StoryIncludes passage',
  775. stacktrace=False)
  776. def viewBuild(self, event=None, name=''):
  777. """
  778. Opens the last built file in a Web browser.
  779. """
  780. path = u'file://' + urllib.pathname2url((name or self.buildDestination).encode('utf-8'))
  781. path = path.replace('file://///', 'file:///')
  782. wx.LaunchDefaultBrowser(path)
  783. def autoBuild(self, event=None):
  784. """
  785. Toggles the autobuild feature
  786. """
  787. if self.autobuildmenuitem.IsChecked():
  788. self.autobuildtimer.Start(5000)
  789. self.autoBuildStart()
  790. else:
  791. self.autobuildtimer.Stop()
  792. def autoBuildTick(self, event=None):
  793. """
  794. Called whenever the autobuild timer checks up on things
  795. """
  796. for pathname, oldmtime in self.autobuildfiles.iteritems():
  797. newmtime = os.stat(pathname).st_mtime
  798. if newmtime != oldmtime:
  799. # print "Auto rebuild triggered by: ", pathname
  800. self.autobuildfiles[pathname] = newmtime
  801. self.rebuild()
  802. break
  803. def autoBuildStart(self):
  804. self.autobuildfiles = {}
  805. if self.saveDestination == '':
  806. twinedocdir = os.getcwd()
  807. else:
  808. twinedocdir = os.path.dirname(self.saveDestination)
  809. widget = self.storyPanel.widgetDict.get('StoryIncludes')
  810. if widget is not None:
  811. for line in widget.passage.text.splitlines():
  812. if not isURL(line):
  813. pathname = os.path.join(twinedocdir, line)
  814. # Include even non-existant files, in case they eventually appear
  815. mtime = os.stat(pathname).st_mtime
  816. self.autobuildfiles[pathname] = mtime
  817. def stats(self, event=None):
  818. """
  819. Displays a StatisticsDialog for this frame.
  820. """
  821. statFrame = StatisticsDialog(parent=self, storyPanel=self.storyPanel, app=self.app)
  822. statFrame.ShowModal()
  823. def showMetadata(self, event=None):
  824. """
  825. Shows a StoryMetadataFrame for this frame.
  826. """
  827. if not hasattr(self, 'metadataFrame'):
  828. self.metadataFrame = StoryMetadataFrame(parent=self, app=self.app)
  829. else:
  830. try:
  831. self.metadataFrame.Raise()
  832. except wx._core.PyDeadObjectError:
  833. # user closed the frame, so we need to recreate it
  834. delattr(self, 'metadataFrame')
  835. self.showMetadata(event)
  836. def showFind(self, event=None):
  837. """
  838. Shows a StoryFindFrame for this frame.
  839. """
  840. if not hasattr(self, 'findFrame'):
  841. self.findFrame = StoryFindFrame(self.storyPanel, self.app)
  842. else:
  843. try:
  844. self.findFrame.Raise()
  845. except wx._core.PyDeadObjectError:
  846. # user closed the frame, so we need to recreate it
  847. delattr(self, 'findFrame')
  848. self.showFind(event)
  849. def showReplace(self, event=None):
  850. """
  851. Shows a StoryReplaceFrame for this frame.
  852. """
  853. if not hasattr(self, 'replaceFrame'):
  854. self.replaceFrame = StoryReplaceFrame(self.storyPanel, self.app)
  855. else:
  856. try:
  857. self.replaceFrame.Raise()
  858. except wx._core.PyDeadObjectError:
  859. # user closed the frame, so we need to recreate it
  860. delattr(self, 'replaceFrame')
  861. self.showReplace(event)
  862. def proof(self, event=None):
  863. """
  864. Builds an RTF version of the story. Pass whether to open the destination file afterwards.
  865. """
  866. # ask for our destination
  867. dialog = wx.FileDialog(self, 'Proof Story', os.getcwd(), "", \
  868. "RTF Document (*.rtf)|*.rtf", \
  869. wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT | wx.FD_CHANGE_DIR)
  870. if dialog.ShowModal() == wx.ID_OK:
  871. path = dialog.GetPath()
  872. dialog.Destroy()
  873. else:
  874. dialog.Destroy()
  875. return
  876. try:
  877. # open destination for writing
  878. dest = open(path, 'w')
  879. # assemble our tiddlywiki and write it out
  880. tw = TiddlyWiki()
  881. for widget in self.storyPanel.sortedWidgets():
  882. # Exclude images from RTF, they appear as large unreadable blobs of base64 text.
  883. if 'Twine.image' not in widget.passage.tags:
  884. tw.addTiddler(widget.passage)
  885. order = [widget.passage.title for widget in self.storyPanel.sortedWidgets()]
  886. dest.write(tw.toRtf(order))
  887. dest.close()
  888. except:
  889. self.app.displayError('building a proofing copy of your story')
  890. def setTarget(self, target):
  891. self.target = target
  892. self.header = self.app.headers[target]
  893. def updateUI(self, event=None):
  894. """Adjusts menu items to reflect the current state."""
  895. selections = self.storyPanel.hasMultipleSelection()
  896. # window title
  897. if self.saveDestination == '':
  898. self.title = StoryFrame.DEFAULT_TITLE
  899. else:
  900. bits = os.path.splitext(self.saveDestination)
  901. self.title = os.path.basename(bits[0])
  902. percent = str(int(round(self.storyPanel.scale * 100)))
  903. dirtyText = '' if not self.dirty else ' *'
  904. titleText = self.title + dirtyText + ' (' + percent + '%) ' + '- ' + self.app.NAME + ' ' + version.versionString
  905. if not self.GetTitle() == titleText:
  906. self.SetTitle(titleText)
  907. if not self.menus:
  908. return
  909. # File menu
  910. self.menus.FindItemById(wx.ID_REVERT_TO_SAVED).Enable(self.saveDestination != '' and self.dirty)
  911. # Edit menu
  912. undoItem = self.menus.FindItemById(wx.ID_UNDO)
  913. undoItem.Enable(self.storyPanel.canUndo())
  914. undoItem.SetText('Undo ' + self.storyPanel.undoAction() + '\tCtrl-Z'
  915. if self.storyPanel.canUndo() else "Can't Undo\tCtrl-Z")
  916. redoItem = self.menus.FindItemById(wx.ID_REDO)
  917. redoItem .Enable(self.storyPanel.canRedo())
  918. redoItem .SetText('Redo ' + self.storyPanel.redoAction() + '\tCtrl-Y'
  919. if self.storyPanel.canRedo() else "Can't Redo\tCtrl-Y")
  920. for item in wx.ID_CUT, wx.ID_COPY, wx.ID_DELETE:
  921. self.menus.FindItemById(item).Enable(selections > 0)
  922. self.menus.FindItemById(StoryFrame.EDIT_FIND_NEXT).Enable(self.storyPanel.lastSearchRegexp is not None)
  923. # View menu
  924. self.menus.FindItemById(StoryFrame.VIEW_TOOLBAR).Check(self.showToolbar)
  925. self.menus.FindItemById(StoryFrame.VIEW_SNAP).Check(self.storyPanel.snapping)
  926. # Story menu, Build menu
  927. editItem = self.menus.FindItemById(wx.ID_EDIT)
  928. testItem = self.menus.FindItemById(StoryFrame.BUILD_TEST_HERE)
  929. if selections == 1:
  930. widget = self.storyPanel.selectedWidget()
  931. editItem.SetItemLabel("Edit \"" + widget.passage.title + "\"")
  932. editItem.Enable(True)
  933. # Only allow test plays from story passages
  934. testItem.SetItemLabel("Test Play From \"" + widget.passage.title + "\""
  935. if widget.passage.isStoryPassage() else "Test Play From Here")
  936. testItem.Enable(widget.passage.isStoryPassage())
  937. else:
  938. editItem.SetItemLabel("&Edit Passage")
  939. editItem.Enable(False)
  940. testItem.SetItemLabel("Test Play From Here")
  941. testItem.Enable(False)
  942. self.menus.FindItemById(StoryFrame.STORY_EDIT_FULLSCREEN).Enable(selections == 1)
  943. self.menus.FindItemById(StoryFrame.BUILD_REBUILD).Enable(self.buildDestination != '')
  944. self.menus.FindItemById(StoryFrame.BUILD_VIEW_LAST).Enable(self.buildDestination != '')
  945. hasStoryIncludes = self.buildDestination != '' and 'StoryIncludes' in self.storyPanel.widgetDict
  946. self.autobuildmenuitem.Enable(hasStoryIncludes)
  947. self.menus.FindItemById(StoryFrame.REFRESH_INCLUDES_LINKS).Enable(hasStoryIncludes)
  948. # Story format submenu
  949. for key in self.storyFormats:
  950. self.menus.FindItemById(key).Check(self.target == self.storyFormats[key].id)
  951. def toggleToolbar(self, event=None):
  952. """Toggles the toolbar onscreen."""
  953. if self.showToolbar:
  954. self.showToolbar = False
  955. self.toolbar.Hide()
  956. self.app.config.WriteBool('storyFrameToolbar', False)
  957. else:
  958. self.showToolbar = True
  959. self.toolbar.Show()
  960. self.app.config.WriteBool('storyFrameToolbar', True)
  961. self.SendSizeEvent()
  962. def setDirty(self, value, action=None):
  963. """
  964. Sets the dirty flag to the value passed. Make sure to use this instead of
  965. setting the dirty property directly, as this method automatically updates
  966. the pristine property as well.
  967. If you pass an action parameter, this action will be saved for undoing under
  968. that name.
  969. """
  970. self.dirty = value
  971. self.pristine = False
  972. if value is True and action:
  973. self.storyPanel.pushUndo(action)
  974. def applyPrefs(self):
  975. """Passes on the apply message to child widgets."""
  976. self.storyPanel.eachWidget(lambda w: w.applyPrefs())
  977. self.storyPanel.Refresh()
  978. def serialize(self):
  979. """Returns a dictionary of state suitable for pickling."""
  980. return {'target': self.target, 'buildDestination': self.buildDestination, \
  981. 'saveDestination': self.saveDestination, \
  982. 'storyPanel': self.storyPanel.serialize(),
  983. 'metadata': self.metadata,
  984. }
  985. def serialize_noprivate(self, dest):
  986. """Returns a dictionary of state suitable for pickling."""
  987. return {'target': self.target, 'buildDestination': '', \
  988. 'saveDestination': dest, \
  989. 'storyPanel': self.storyPanel.serialize_noprivate(),
  990. 'metadata': self.metadata,
  991. }
  992. def __repr__(self):
  993. return "<StoryFrame '" + self.saveDestination + "'>"
  994. def getHeader(self):
  995. """Returns the current selected target header for this Story Frame."""
  996. return self.header
  997. # menu constants
  998. # (that aren't already defined by wx)
  999. FILE_IMPORT_SOURCE = 101
  1000. FILE_EXPORT_PROOF = 102
  1001. FILE_EXPORT_SOURCE = 103
  1002. FILE_IMPORT_HTML = 104
  1003. EDIT_FIND_NEXT = 201
  1004. VIEW_SNAP = 301
  1005. VIEW_CLEANUP = 302
  1006. VIEW_TOOLBAR = 303
  1007. [STORY_NEW_PASSAGE, STORY_NEW_SCRIPT, STORY_NEW_STYLESHEET, STORY_NEW_ANNOTATION, STORY_EDIT_FULLSCREEN,
  1008. STORY_STATS, STORY_METADATA, \
  1009. STORY_IMPORT_IMAGE, STORY_IMPORT_IMAGE_URL, STORY_IMPORT_FONT, STORY_FORMAT_HELP, STORYSETTINGS_START,
  1010. STORYSETTINGS_TITLE, STORYSETTINGS_SUBTITLE, STORYSETTINGS_AUTHOR, \
  1011. STORYSETTINGS_MENU, STORYSETTINGS_SETTINGS, STORYSETTINGS_INCLUDES, STORYSETTINGS_INIT, STORYSETTINGS_HELP,
  1012. REFRESH_INCLUDES_LINKS] = range(401, 422)
  1013. STORY_FORMAT_BASE = 501
  1014. [BUILD_VERIFY, BUILD_TEST, BUILD_TEST_HERE, BUILD_BUILD, BUILD_REBUILD, BUILD_VIEW_LAST, BUILD_AUTO_BUILD] = range(
  1015. 601, 608)
  1016. [HELP_MANUAL, HELP_GROUP, HELP_GITHUB, HELP_FORUM] = range(701, 705)
  1017. # tooltip labels
  1018. NEW_PASSAGE_TOOLTIP = 'Add a new passage to your story'
  1019. ZOOM_IN_TOOLTIP = 'Zoom in'
  1020. ZOOM_OUT_TOOLTIP = 'Zoom out'
  1021. ZOOM_FIT_TOOLTIP = 'Zoom so all passages are visible onscreen'
  1022. ZOOM_ONE_TOOLTIP = 'Zoom to 100%'
  1023. # size constants
  1024. DEFAULT_SIZE = (800, 600)
  1025. TOOLBAR_ICON_SIZE = 32
  1026. # misc stuff
  1027. DEFAULT_TITLE = 'Untitled Story'
  1028. class ClipboardMonitor(wx.Timer):
  1029. """
  1030. Monitors the clipboard and notifies a callback when the format of the contents
  1031. changes from or to Twine passage data.
  1032. """
  1033. def __init__(self, callback):
  1034. wx.Timer.__init__(self)
  1035. self.callback = callback
  1036. self.dataFormat = wx.CustomDataFormat(StoryPanel.CLIPBOARD_FORMAT)
  1037. self.state = None
  1038. def Notify(self, *args, **kwargs):
  1039. if wx.TheClipboard.Open():
  1040. newState = wx.TheClipboard.IsSupported(self.dataFormat)
  1041. wx.TheClipboard.Close()
  1042. if newState != self.state:
  1043. self.state = newState
  1044. self.callback(newState)