storypanel.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179
  1. from collections import defaultdict
  2. from itertools import izip, chain
  3. import sys, wx, re, pickle
  4. import geometry
  5. from tiddlywiki import TiddlyWiki
  6. from passagewidget import PassageWidget
  7. class StoryPanel(wx.ScrolledWindow):
  8. """
  9. A StoryPanel is a container for PassageWidgets. It translates
  10. between logical coordinates and pixel coordinates as the user
  11. zooms in and out, and communicates those changes to its widgets.
  12. A discussion on coordinate systems: logical coordinates are notional,
  13. and do not change as the user zooms in and out. Pixel coordinates
  14. are extremely literal: (0, 0) is the top-left corner visible to the
  15. user, no matter where the scrollbar position is.
  16. This class (and PassageWidget) deal strictly in logical coordinates, but
  17. incoming events are in pixel coordinates. We convert these to logical
  18. coordinates as soon as possible.
  19. """
  20. def __init__(self, parent, app, id = wx.ID_ANY, state = None):
  21. wx.ScrolledWindow.__init__(self, parent, id)
  22. self.app = app
  23. self.parent = parent
  24. # inner state
  25. self.snapping = self.app.config.ReadBool('storyPanelSnap')
  26. self.widgetDict = dict()
  27. self.visibleWidgets = None
  28. self.includedPassages = set()
  29. self.draggingMarquee = False
  30. self.draggingWidgets = None
  31. self.notDraggingWidgets = None
  32. self.undoStack = []
  33. self.undoPointer = -1
  34. self.lastSearchRegexp = None
  35. self.lastSearchFlags = None
  36. self.lastScrollPos = -1
  37. self.trackinghover = None
  38. self.tooltiptimer = wx.PyTimer(self.tooltipShow)
  39. self.tooltipplace = None
  40. self.tooltipobj = None
  41. self.textDragSource = None
  42. if state:
  43. self.scale = state['scale']
  44. for widget in state['widgets']:
  45. pw = PassageWidget(self, self.app, state = widget)
  46. self.widgetDict[pw.passage.title] = pw
  47. if 'snapping' in state:
  48. self.snapping = state['snapping']
  49. else:
  50. self.scale = 1
  51. for title in ('Start', 'StoryTitle', 'StoryAuthor'):
  52. self.newWidget(title = title, text = self.parent.defaultTextForPassage(title), quietly = True)
  53. self.pushUndo(action = '')
  54. self.undoPointer -= 1
  55. # cursors
  56. self.dragCursor = wx.StockCursor(wx.CURSOR_SIZING)
  57. self.badDragCursor = wx.StockCursor(wx.CURSOR_NO_ENTRY)
  58. self.scrollCursor = wx.StockCursor(wx.CURSOR_SIZING)
  59. self.defaultCursor = wx.StockCursor(wx.CURSOR_ARROW)
  60. self.SetCursor(self.defaultCursor)
  61. # events
  62. self.SetDropTarget(StoryPanelDropTarget(self))
  63. self.Bind(wx.EVT_ERASE_BACKGROUND, lambda e: e)
  64. self.Bind(wx.EVT_PAINT, self.paint)
  65. self.Bind(wx.EVT_SIZE, self.resize)
  66. self.Bind(wx.EVT_LEFT_DOWN, self.handleClick)
  67. self.Bind(wx.EVT_LEFT_DCLICK, self.handleDoubleClick)
  68. self.Bind(wx.EVT_RIGHT_UP, self.handleRightClick)
  69. self.Bind(wx.EVT_MIDDLE_UP, self.handleMiddleClick)
  70. self.Bind(wx.EVT_ENTER_WINDOW, self.handleHoverStart)
  71. self.Bind(wx.EVT_LEAVE_WINDOW, self.handleHoverStop)
  72. self.Bind(wx.EVT_MOTION, self.handleHover)
  73. def newWidget(self, title = None, text = '', tags = (), pos = None, quietly = False, logicals = False):
  74. """Adds a new widget to the container."""
  75. # defaults
  76. if not title:
  77. if tags and tags[0] in TiddlyWiki.INFO_TAGS:
  78. type = "Untitled " + tags[0].capitalize()
  79. else:
  80. type = "Untitled Passage"
  81. title = self.untitledName(type)
  82. if not pos: pos = StoryPanel.INSET
  83. if not logicals: pos = self.toLogical(pos)
  84. new = PassageWidget(self, self.app, title = title, text = text, tags = tags, pos = pos)
  85. self.widgetDict[new.passage.title] = new
  86. self.snapWidget(new, quietly)
  87. self.resize()
  88. self.Refresh()
  89. if not quietly: self.parent.setDirty(True, action = 'New Passage')
  90. return new
  91. def changeWidgetTitle(self, oldTitle, newTitle):
  92. widget = self.widgetDict.pop(oldTitle)
  93. widget.passage.title = newTitle
  94. self.widgetDict[newTitle] = widget
  95. def snapWidget(self, widget, quickly = False):
  96. """
  97. Snaps a widget to our grid if self.snapping is set.
  98. Then, call findSpace()
  99. """
  100. if self.snapping:
  101. pos = list(widget.pos)
  102. for coord in range(2):
  103. distance = pos[coord] % StoryPanel.GRID_SPACING
  104. if distance > StoryPanel.GRID_SPACING / 2:
  105. pos[coord] += StoryPanel.GRID_SPACING - distance
  106. else:
  107. pos[coord] -= distance
  108. pos[coord] += StoryPanel.INSET[coord]
  109. widget.pos = pos
  110. self.Refresh()
  111. if quickly:
  112. widget.findSpaceQuickly()
  113. else:
  114. widget.findSpace()
  115. def cleanup(self):
  116. """Snaps all widgets to the grid."""
  117. oldSnapping = self.snapping
  118. self.snapping = True
  119. self.eachWidget(self.snapWidget)
  120. self.snapping = oldSnapping
  121. self.parent.setDirty(True, action = 'Clean Up')
  122. self.Refresh()
  123. def toggleSnapping(self):
  124. """Toggles whether snapping is on."""
  125. self.snapping = self.snapping is not True
  126. self.app.config.WriteBool('storyPanelSnap', self.snapping)
  127. def copyWidgets(self):
  128. """Copies selected widgets into the clipboard."""
  129. data = []
  130. for widget in self.widgetDict.itervalues():
  131. if widget.selected: data.append(widget.serialize())
  132. clipData = wx.CustomDataObject(wx.CustomDataFormat(StoryPanel.CLIPBOARD_FORMAT))
  133. clipData.SetData(pickle.dumps(data, 1))
  134. if wx.TheClipboard.Open():
  135. wx.TheClipboard.SetData(clipData)
  136. wx.TheClipboard.Close()
  137. def cutWidgets(self):
  138. """Cuts selected widgets into the clipboard."""
  139. self.copyWidgets()
  140. self.removeWidgets()
  141. self.Refresh()
  142. def pasteWidgets(self, pos = (0,0), logicals = False):
  143. """Pastes widgets from the clipboard."""
  144. clipFormat = wx.CustomDataFormat(StoryPanel.CLIPBOARD_FORMAT)
  145. clipData = wx.CustomDataObject(clipFormat)
  146. if wx.TheClipboard.Open():
  147. gotData = wx.TheClipboard.IsSupported(clipFormat) and wx.TheClipboard.GetData(clipData)
  148. wx.TheClipboard.Close()
  149. if gotData:
  150. data = pickle.loads(clipData.GetData())
  151. self.eachWidget(lambda w: w.setSelected(False, False))
  152. if not pos: pos = StoryPanel.INSET
  153. if not logicals: pos = self.toLogical(pos)
  154. for widget in data:
  155. newPassage = PassageWidget(self, self.app, state = widget, pos = pos, title = self.untitledName(widget['passage'].title))
  156. newPassage.findSpace()
  157. newPassage.setSelected(True, False)
  158. self.widgetDict[newPassage.passage.title] = newPassage
  159. self.snapWidget(newPassage, False)
  160. self.parent.setDirty(True, action = 'Paste')
  161. self.resize()
  162. self.Refresh()
  163. def removeWidget(self, title, saveUndo = True):
  164. """
  165. Deletes a passed widget. You can ask this to save an undo state manually,
  166. but by default, it doesn't.
  167. """
  168. widget = self.widgetDict.pop(title, None)
  169. if widget is None:
  170. return
  171. if widget in self.visibleWidgets: self.visibleWidgets.remove(widget)
  172. if self.tooltipplace is widget:
  173. self.tooltipplace = None
  174. if saveUndo: self.parent.setDirty(True, action = 'Delete')
  175. self.Refresh()
  176. def removeWidgets(self, event = None, saveUndo = True):
  177. """
  178. Deletes all selected widgets. You can ask this to save an undo state manually,
  179. but by default, it doesn't.
  180. """
  181. selected = set(
  182. title
  183. for title, widget in self.widgetDict.iteritems()
  184. if widget.selected
  185. )
  186. connected = set(
  187. title
  188. for title, widget in self.widgetDict.iteritems()
  189. if not widget.selected and selected.intersection(widget.linksAndDisplays())
  190. )
  191. if connected:
  192. message = 'Are you sure you want to delete ' + \
  193. (('"' + next(iter(selected)) + '"? Links to it') if len(selected) == 1 else
  194. (str(len(selected)) + ' passages? Links to them')) + \
  195. ' from ' + \
  196. (('"' + next(iter(connected)) + '"') if len(connected) == 1 else
  197. (str(len(connected)) + ' other passages')) + \
  198. ' will become broken.'
  199. dialog = wx.MessageDialog(self.parent, message,
  200. 'Delete Passage' + ('s' if len(selected) > 1 else ''), \
  201. wx.ICON_WARNING | wx.OK | wx.CANCEL )
  202. if dialog.ShowModal() != wx.ID_OK:
  203. return
  204. for title in selected:
  205. self.removeWidget(title, saveUndo)
  206. def findWidgetRegexp(self, regexp = None, flags = None):
  207. """
  208. Finds the next PassageWidget that matches the regexp passed.
  209. You may leave off the regexp, in which case it uses the last
  210. search performed. This begins its search from the current selection.
  211. If nothing is found, then an error alert is shown.
  212. """
  213. if regexp is None:
  214. regexp = self.lastSearchRegexp
  215. flags = self.lastSearchFlags
  216. self.lastSearchRegexp = regexp
  217. self.lastSearchFlags = flags
  218. # find the current selection
  219. # if there are multiple selections, we just use the first
  220. i = -1
  221. # look for selected PassageWidgets
  222. widgets = self.widgetDict.values()
  223. for num, widget in enumerate(widgets):
  224. if widget.selected:
  225. i = num
  226. break
  227. # if no widget is selected, start at first widget
  228. if i==len(widgets)-1:
  229. i=-1
  230. for widget in widgets:
  231. if widget.containsRegexp(regexp, flags):
  232. widget.setSelected(True)
  233. self.scrollToWidget(widget)
  234. return
  235. i += 1
  236. # fallthrough: text not found
  237. dialog = wx.MessageDialog(self, 'The text you entered was not found in your story.', \
  238. 'Not Found', wx.ICON_INFORMATION | wx.OK)
  239. dialog.ShowModal()
  240. def replaceRegexpInSelectedWidget(self, findRegexp, replacementRegexp, flags):
  241. for widget in self.widgetDict.values():
  242. if widget.selected:
  243. widget.replaceRegexp(findRegexp, replacementRegexp, flags)
  244. widget.clearPaintCache()
  245. self.Refresh()
  246. self.parent.setDirty(True, action = 'Replace in Currently Selected Widget')
  247. def replaceRegexpInWidgets(self, findRegexp, replacementRegexp, flags):
  248. """
  249. Performs a string replace on all widgets in this StoryPanel.
  250. It shows an alert once done to tell the user how many replacements were
  251. made.
  252. """
  253. replacements = 0
  254. for widget in self.widgetDict.values():
  255. replacements += widget.replaceRegexp(findRegexp, replacementRegexp, flags)
  256. if replacements > 0:
  257. self.Refresh()
  258. self.parent.setDirty(True, action = 'Replace Across Entire Story')
  259. message = '%d replacement' % replacements
  260. if replacements != 1:
  261. message += 's were '
  262. else:
  263. message += ' was '
  264. message += 'made in your story.'
  265. dialog = wx.MessageDialog(self, message, 'Replace Complete', wx.ICON_INFORMATION | wx.OK)
  266. dialog.ShowModal()
  267. def scrollToWidget(self, widget):
  268. """
  269. Scrolls so that the widget passed is visible.
  270. """
  271. widgetRect = widget.getPixelRect()
  272. xUnit,yUnit = self.GetScrollPixelsPerUnit()
  273. sx = (widgetRect.x-20) / float(xUnit)
  274. sy = (widgetRect.y-20) / float(yUnit)
  275. self.Scroll(max(sx, 0), max(sy - 20, 0))
  276. def pushUndo(self, action):
  277. """
  278. Pushes the current state onto the undo stack. The name parameter describes
  279. the action that triggered this call, and is displayed in the Undo menu.
  280. """
  281. # delete anything above the undoPointer
  282. while self.undoPointer < len(self.undoStack) - 2: self.undoStack.pop()
  283. # add a new state onto the stack
  284. state = { 'action': action, 'widgets': [] }
  285. for widget in self.widgetDict.itervalues(): state['widgets'].append(widget.serialize())
  286. self.undoStack.append(state)
  287. self.undoPointer += 1
  288. def undo(self):
  289. """
  290. Restores the undo state at self.undoPointer to the current view, then
  291. decreases self.undoPointer by 1.
  292. """
  293. self.widgetDict = dict()
  294. self.visibleWidgets = None
  295. state = self.undoStack[self.undoPointer]
  296. for widgetState in state['widgets']:
  297. widget = PassageWidget(self, self.app, state = widgetState)
  298. self.widgetDict[widget.passage.title] = widget
  299. self.undoPointer -= 1
  300. self.Refresh()
  301. def redo(self):
  302. """
  303. Moves the undo pointer up 2, then calls undo() to restore state.
  304. """
  305. self.undoPointer += 2
  306. self.undo()
  307. def canUndo(self):
  308. """Returns whether an undo is available to the user."""
  309. return self.undoPointer > -1
  310. def undoAction(self):
  311. """Returns the name of the action that the user will be undoing."""
  312. return self.undoStack[self.undoPointer + 1]['action']
  313. def canRedo(self):
  314. """Returns whether a redo is available to the user."""
  315. return self.undoPointer < len(self.undoStack) - 2
  316. def redoAction(self):
  317. """Returns the name of the action that the user will be redoing."""
  318. return self.undoStack[self.undoPointer + 2]['action']
  319. def handleClick(self, event):
  320. """
  321. Passes off execution to either startMarquee or startDrag,
  322. depending on whether the user clicked a widget.
  323. """
  324. # start a drag if the user clicked a widget
  325. # or a marquee if they didn't
  326. for widget in self.widgetDict.itervalues():
  327. if widget.getPixelRect().Contains(event.GetPosition()):
  328. if not widget.selected: widget.setSelected(True, not event.ShiftDown())
  329. self.startDrag(event, widget)
  330. return
  331. self.startMarquee(event)
  332. def handleDoubleClick(self, event):
  333. """Dispatches an openEditor() call to a widget the user clicked."""
  334. for widget in self.widgetDict.itervalues():
  335. if widget.getPixelRect().Contains(event.GetPosition()): widget.openEditor()
  336. def handleRightClick(self, event):
  337. """Either opens our own contextual menu, or passes it off to a widget."""
  338. for widget in self.widgetDict.itervalues():
  339. if widget.getPixelRect().Contains(event.GetPosition()):
  340. widget.openContextMenu(event)
  341. return
  342. self.PopupMenu(StoryPanelContext(self, event.GetPosition()), event.GetPosition())
  343. def handleMiddleClick(self, event):
  344. """Creates a new widget centered at the mouse position."""
  345. pos = event.GetPosition()
  346. offset = self.toPixels((PassageWidget.SIZE / 2, 0), scaleOnly = True)
  347. pos.x = pos.x - offset[0]
  348. pos.y = pos.y - offset[0]
  349. self.newWidget(pos = pos)
  350. def startMarquee(self, event):
  351. """Starts a marquee selection."""
  352. if not self.draggingMarquee:
  353. self.draggingMarquee = True
  354. self.dragOrigin = event.GetPosition()
  355. self.dragCurrent = event.GetPosition()
  356. self.dragRect = geometry.pointsToRect(self.dragOrigin, self.dragOrigin)
  357. # deselect everything
  358. for widget in self.widgetDict.itervalues():
  359. widget.setSelected(False, False)
  360. # grab mouse focus
  361. self.Bind(wx.EVT_MOUSE_EVENTS, self.followMarquee)
  362. self.CaptureMouse()
  363. self.Refresh()
  364. def followMarquee(self, event):
  365. """
  366. Follows the mouse during a marquee selection.
  367. """
  368. if event.LeftIsDown():
  369. # scroll and adjust coordinates
  370. offset = self.scrollWithMouse(event)
  371. self.oldDirtyRect = self.dragRect.Inflate(2, 2)
  372. self.oldDirtyRect.x -= offset[0]
  373. self.oldDirtyRect.y -= offset[1]
  374. self.dragCurrent = event.GetPosition()
  375. self.dragOrigin.x -= offset[0]
  376. self.dragOrigin.y -= offset[1]
  377. self.dragCurrent.x -= offset[0]
  378. self.dragCurrent.y -= offset[1]
  379. # dragRect is what is drawn onscreen
  380. # it is in unscrolled coordinates
  381. self.dragRect = geometry.pointsToRect(self.dragOrigin, self.dragCurrent)
  382. # select all enclosed widgets
  383. logicalOrigin = self.toLogical(self.CalcUnscrolledPosition(self.dragRect.x, self.dragRect.y), scaleOnly = True)
  384. logicalSize = self.toLogical((self.dragRect.width, self.dragRect.height), scaleOnly = True)
  385. logicalRect = wx.Rect(logicalOrigin[0], logicalOrigin[1], logicalSize[0], logicalSize[1])
  386. for widget in self.widgetDict.itervalues():
  387. widget.setSelected(widget.intersects(logicalRect), False)
  388. self.Refresh(True, self.oldDirtyRect.Union(self.dragRect))
  389. else:
  390. self.draggingMarquee = False
  391. # clear event handlers
  392. self.Bind(wx.EVT_MOUSE_EVENTS, None)
  393. self.ReleaseMouse()
  394. self.Refresh()
  395. def startDrag(self, event, clickedWidget):
  396. """
  397. Starts a widget drag. The initial event is caught by PassageWidget, but
  398. it passes control to us so that we can move all selected widgets at once.
  399. """
  400. if not self.draggingWidgets or not len(self.draggingWidgets):
  401. # cache the sets of dragged vs not-dragged widgets
  402. self.draggingWidgets = []
  403. self.notDraggingWidgets = []
  404. self.clickedWidget = clickedWidget
  405. self.actuallyDragged = False
  406. self.dragCurrent = event.GetPosition()
  407. self.oldDirtyRect = clickedWidget.getPixelRect()
  408. # have selected widgets remember their original position
  409. # in case they need to snap back to it after a bad drag
  410. for widget in self.widgetDict.itervalues():
  411. if widget.selected:
  412. self.draggingWidgets.append(widget)
  413. widget.predragPos = widget.pos
  414. else:
  415. self.notDraggingWidgets.append(widget)
  416. # grab mouse focus
  417. self.Bind(wx.EVT_MOUSE_EVENTS, self.followDrag)
  418. self.CaptureMouse()
  419. def followDrag(self, event):
  420. """Follows mouse motions during a widget drag."""
  421. if event.LeftIsDown():
  422. self.actuallyDragged = True
  423. pos = event.GetPosition()
  424. # find change in position
  425. deltaX = pos[0] - self.dragCurrent[0]
  426. deltaY = pos[1] - self.dragCurrent[1]
  427. deltaX = self.toLogical((deltaX, -1), scaleOnly = True)[0]
  428. deltaY = self.toLogical((deltaY, -1), scaleOnly = True)[0]
  429. # offset selected passages
  430. for widget in self.draggingWidgets: widget.offset(deltaX, deltaY)
  431. self.dragCurrent = pos
  432. # if there any overlaps, then warn the user with a bad drag cursor
  433. goodDrag = True
  434. for widget in self.draggingWidgets:
  435. if widget.intersectsAny(dragging = True):
  436. goodDrag = False
  437. break
  438. # in fast drawing, we dim passages
  439. # to indicate no connectors should be drawn for them
  440. # while dragging is occurring
  441. #
  442. # in slow drawing, we dim passages
  443. # to indicate you're not allowed to drag there
  444. for widget in self.draggingWidgets:
  445. widget.setDimmed(self.app.config.ReadBool('fastStoryPanel') or not goodDrag)
  446. if goodDrag: self.SetCursor(self.dragCursor)
  447. else: self.SetCursor(self.badDragCursor)
  448. # scroll in response to the mouse,
  449. # and shift passages accordingly
  450. widgetScroll = self.toLogical(self.scrollWithMouse(event), scaleOnly = True)
  451. for widget in self.draggingWidgets: widget.offset(widgetScroll[0], widgetScroll[1])
  452. # figure out our dirty rect
  453. dirtyRect = self.oldDirtyRect
  454. for widget in self.draggingWidgets:
  455. dirtyRect.Union(widget.getDirtyPixelRect())
  456. for link in widget.linksAndDisplays():
  457. widget2 = self.findWidget(link)
  458. if widget2:
  459. dirtyRect.Union(widget2.getDirtyPixelRect())
  460. self.Refresh(True, dirtyRect)
  461. else:
  462. if self.actuallyDragged:
  463. # is this a bad drag?
  464. goodDrag = True
  465. for widget in self.draggingWidgets:
  466. if widget.intersectsAny(dragging = True):
  467. goodDrag = False
  468. break
  469. if goodDrag:
  470. for widget in self.draggingWidgets:
  471. self.snapWidget(widget)
  472. widget.setDimmed(False)
  473. if widget.pos != widget.predragPos:
  474. self.parent.setDirty(True, action = 'Move')
  475. self.resize()
  476. else:
  477. for widget in self.draggingWidgets:
  478. widget.pos = widget.predragPos
  479. widget.setDimmed(False)
  480. self.Refresh()
  481. else:
  482. # change the selection
  483. self.clickedWidget.setSelected(True, not event.ShiftDown())
  484. # general cleanup
  485. self.draggingWidgets = None
  486. self.notDraggingWidgets = None
  487. self.Bind(wx.EVT_MOUSE_EVENTS, None)
  488. self.ReleaseMouse()
  489. self.SetCursor(self.defaultCursor)
  490. def scrollWithMouse(self, event):
  491. """
  492. If the user has moved their mouse outside the window
  493. bounds, this tries to scroll to keep up. This returns a tuple
  494. of pixels of the scrolling; if none has happened, it returns (0, 0).
  495. """
  496. pos = event.GetPosition()
  497. size = self.GetSize()
  498. scroll = [0, 0]
  499. changed = False
  500. if pos.x < 0:
  501. scroll[0] = -1
  502. changed = True
  503. else:
  504. if pos.x > size[0]:
  505. scroll[0] = 1
  506. changed = True
  507. if pos.y < 0:
  508. scroll[1] = -1
  509. changed = True
  510. else:
  511. if pos.y > size[1]:
  512. scroll[1] = 1
  513. changed = True
  514. pixScroll = [0, 0]
  515. if changed:
  516. # scroll the window
  517. oldPos = self.GetViewStart()
  518. self.Scroll(oldPos[0] + scroll[0], oldPos[1] + scroll[1])
  519. # return pixel change
  520. # check to make sure we actually were able to scroll the direction we asked
  521. newPos = self.GetViewStart()
  522. if oldPos[0] != newPos[0]:
  523. pixScroll[0] = scroll[0] * StoryPanel.SCROLL_SPEED
  524. if oldPos[1] != newPos[1]:
  525. pixScroll[1] = scroll[1] * StoryPanel.SCROLL_SPEED
  526. return pixScroll
  527. def untitledName(self, base = 'Untitled Passage'):
  528. """Returns a string for an untitled PassageWidget."""
  529. number = 1
  530. if not base.startswith('Untitled ') and base not in self.widgetDict:
  531. return base
  532. for widget in self.widgetDict.itervalues():
  533. match = re.match(re.escape(base) + ' (\d+)', widget.passage.title)
  534. if match: number = int(match.group(1)) + 1
  535. return base + ' ' + str(number)
  536. def eachWidget(self, function):
  537. """Runs a function on every passage in the panel."""
  538. for widget in self.widgetDict.values():
  539. function(widget)
  540. def sortedWidgets(self):
  541. """Returns a sorted list of widgets, left to right, top to bottom."""
  542. return sorted(self.widgetDict.itervalues(), PassageWidget.posCompare)
  543. def taggedWidgets(self, tag):
  544. """Returns widgets that have the given tag"""
  545. return (a for a in self.widgetDict.itervalues() if tag in a.passage.tags)
  546. def selectedWidget(self):
  547. """Returns any one selected widget."""
  548. for widget in self.widgetDict.itervalues():
  549. if widget.selected: return widget
  550. return None
  551. def eachSelectedWidget(self, function):
  552. """Runs a function on every selected passage in the panel."""
  553. for widget in self.widgetDict.values():
  554. if widget.selected: function(widget)
  555. def hasSelection(self):
  556. """Returns whether any passages are selected."""
  557. for widget in self.widgetDict.itervalues():
  558. if widget.selected: return True
  559. return False
  560. def hasMultipleSelection(self):
  561. """Returns 0 if no passages are selected, one if one or two if two or more are selected."""
  562. selected = 0
  563. for widget in self.widgetDict.itervalues():
  564. if widget.selected:
  565. selected += 1
  566. if selected > 1:
  567. return selected
  568. return selected
  569. def findWidget(self, title):
  570. """Returns a PassageWidget with the title passed. If none exists, it returns None."""
  571. return self.widgetDict.get(title)
  572. def passageExists(self, title, includeIncluded = True):
  573. """
  574. Returns whether a given passage exists in the story.
  575. If includeIncluded then will also check external passages referenced via StoryIncludes
  576. """
  577. return title in self.widgetDict or (includeIncluded and self.includedPassageExists(title))
  578. def clearIncludedPassages(self):
  579. """Clear the includedPassages set"""
  580. self.includedPassages.clear()
  581. def addIncludedPassage(self, title):
  582. """Add a title to the set of external passages"""
  583. self.includedPassages.add(title)
  584. def includedPassageExists(self, title):
  585. """Add a title to the set of external passages"""
  586. return title in self.includedPassages
  587. def refreshIncludedPassageList(self):
  588. def callback(passage):
  589. if passage.title == 'StoryIncludes' or passage.title in self.widgetDict:
  590. return
  591. self.addIncludedPassage(passage.title)
  592. self.clearIncludedPassages()
  593. widget = self.widgetDict.get('StoryIncludes')
  594. if widget is not None:
  595. self.parent.readIncludes(widget.passage.text.splitlines(), callback, silent = True)
  596. def toPixels(self, logicals, scaleOnly = False):
  597. """
  598. Converts a tuple of logical coordinates to pixel coordinates. If you need to do just
  599. a straight conversion from logicals to pixels without worrying about where the scrollbar
  600. is, then call with scaleOnly set to True.
  601. """
  602. converted = (logicals[0] * self.scale, logicals[1] * self.scale)
  603. if scaleOnly:
  604. return converted
  605. return self.CalcScrolledPosition(converted)
  606. def toLogical(self, pixels, scaleOnly = False):
  607. """
  608. Converts a tuple of pixel coordinates to logical coordinates. If you need to do just
  609. a straight conversion without worrying about where the scrollbar is, then call with
  610. scaleOnly set to True.
  611. """
  612. # order of operations here is important, though I don't totally understand why
  613. if scaleOnly:
  614. converted = pixels
  615. else:
  616. converted = self.CalcUnscrolledPosition(pixels)
  617. converted = (converted[0] / self.scale, converted[1] / self.scale)
  618. return converted
  619. def getSize(self):
  620. """
  621. Returns a tuple (width, height) of the smallest rect needed to
  622. contain all children widgets.
  623. """
  624. width, height = 0, 0
  625. for i in self.widgetDict.itervalues():
  626. rightSide = i.pos[0] + i.getSize()[0]
  627. bottomSide = i.pos[1] + i.getSize()[1]
  628. width = max(width, rightSide)
  629. height = max(height, bottomSide)
  630. return (width, height)
  631. def zoom(self, scale):
  632. """
  633. Sets zoom to a certain level. Pass a number to set the zoom
  634. exactly, pass 'in' or 'out' to zoom relatively, and 'fit'
  635. to set the zoom so that all children are visible.
  636. """
  637. oldScale = self.scale
  638. if isinstance(scale, float):
  639. self.scale = scale
  640. elif scale == 'in':
  641. self.scale += 0.2
  642. elif scale == 'out':
  643. self.scale -= 0.2
  644. elif scale == 'fit':
  645. self.zoom(1.0)
  646. neededSize = self.toPixels(self.getSize(), scaleOnly = True)
  647. actualSize = self.GetSize()
  648. widthRatio = actualSize.width / neededSize[0]
  649. heightRatio = actualSize.height / neededSize[1]
  650. self.scale = min(widthRatio, heightRatio)
  651. self.Scroll(0, 0)
  652. self.scale = max(self.scale, 0.2)
  653. scaleDelta = self.scale - oldScale
  654. # figure out what our scroll bar positions should be moved to
  655. # to keep in scale
  656. origin = list(self.GetViewStart())
  657. origin[0] += scaleDelta * origin[0]
  658. origin[1] += scaleDelta * origin[1]
  659. self.resize()
  660. self.Refresh()
  661. self.Scroll(origin[0], origin[1])
  662. self.parent.updateUI()
  663. def arrowPolygonsToLines(self, list):
  664. for polygon in list:
  665. yield polygon[0][0], polygon[0][1], polygon[1][0], polygon[1][1]
  666. yield polygon[1][0], polygon[1][1], polygon[2][0], polygon[2][1]
  667. def paint(self, event):
  668. """Paints marquee selection, widget connectors, and widgets onscreen."""
  669. # do NOT call self.DoPrepareDC() no matter what the docs may say
  670. # we already take into account our scroll origin in our
  671. # toPixels() method
  672. # OS X already double buffers drawing for us; if we try to do it
  673. # ourselves, performance is horrendous
  674. if sys.platform == 'darwin':
  675. gc = wx.PaintDC(self)
  676. else:
  677. gc = wx.BufferedPaintDC(self)
  678. updateRect = self.updateVisableRectsAndReturnUpdateRegion()
  679. # background
  680. gc.SetBrush(wx.Brush(StoryPanel.FLAT_BG_COLOR if self.app.config.ReadBool('flatDesign')
  681. else StoryPanel.BACKGROUND_COLOR))
  682. gc.DrawRectangle(updateRect.x - 1, updateRect.y - 1, updateRect.width + 2, updateRect.height + 2)
  683. # connectors
  684. arrowheads = (self.scale > StoryPanel.ARROWHEAD_THRESHOLD)
  685. lineDictonary = defaultdict(list)
  686. arrowDictonary = defaultdict(list) if arrowheads else None
  687. displayArrows = self.app.config.ReadBool('displayArrows')
  688. imageArrows = self.app.config.ReadBool('imageArrows')
  689. flatDesign = self.app.config.ReadBool('flatDesign')
  690. for widget in self.visibleWidgets:
  691. if not widget.dimmed:
  692. widget.addConnectorLinesToDict(displayArrows, imageArrows, flatDesign, lineDictonary, arrowDictonary, updateRect)
  693. for (color, width) in lineDictonary.iterkeys():
  694. gc.SetPen(wx.Pen(color, width))
  695. lines = list(izip(*[iter(chain(*lineDictonary[(color, width)]))] * 4))
  696. gc.DrawLineList(lines)
  697. if arrowheads:
  698. for (color, width) in arrowDictonary.iterkeys():
  699. gc.SetPen(wx.Pen(color, width))
  700. arrows = arrowDictonary[(color, width)]
  701. if self.app.config.ReadBool('flatDesign'):
  702. gc.SetBrush(wx.Brush(color))
  703. gc.DrawPolygonList(arrows)
  704. else:
  705. lines = list(self.arrowPolygonsToLines(arrows))
  706. gc.DrawLineList(lines)
  707. for widget in self.visibleWidgets:
  708. # Could be "visible" only insofar as its arrow is visible
  709. if updateRect.Intersects(widget.getPixelRect()):
  710. widget.paint(gc)
  711. # marquee selection
  712. # with slow drawing, use alpha blending for interior
  713. if self.draggingMarquee:
  714. if self.app.config.ReadBool('fastStoryPanel'):
  715. gc.SetPen(wx.Pen('#ffffff', 1, wx.DOT))
  716. gc.SetBrush(wx.Brush(wx.WHITE, wx.TRANSPARENT))
  717. else:
  718. gc = wx.GraphicsContext.Create(gc)
  719. marqueeColor = wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT)
  720. gc.SetPen(wx.Pen(marqueeColor))
  721. r, g, b = marqueeColor.Get(False)
  722. marqueeColor = wx.Colour(r, g, b, StoryPanel.MARQUEE_ALPHA)
  723. gc.SetBrush(wx.Brush(marqueeColor))
  724. gc.DrawRectangle(self.dragRect.x, self.dragRect.y, self.dragRect.width, self.dragRect.height)
  725. def updateVisableRectsAndReturnUpdateRegion(self):
  726. """
  727. Updates the self.visibleWidgets list if necessary based on the current scroll position.
  728. :return: The update region that would need to be redrawn
  729. """
  730. # Determine visible passages
  731. updateRect = self.GetUpdateRegion().GetBox()
  732. scrollPos = (self.GetScrollPos(wx.HORIZONTAL), self.GetScrollPos(wx.VERTICAL))
  733. if self.visibleWidgets is None or scrollPos != self.lastScrollPos:
  734. self.lastScrollPos = scrollPos
  735. updateRect = self.GetClientRect()
  736. displayArrows = self.app.config.ReadBool('displayArrows')
  737. imageArrows = self.app.config.ReadBool('imageArrows')
  738. self.visibleWidgets = [widget for widget in self.widgetDict.itervalues()
  739. # It's visible if it's in the client rect, or is being moved.
  740. if (widget.dimmed
  741. or updateRect.Intersects(widget.getPixelRect())
  742. # It's also visible if an arrow FROM it intersects with the Client Rect
  743. or [w2 for w2 in widget.getConnectedWidgets(displayArrows, imageArrows)
  744. if geometry.lineRectIntersection(widget.getConnectorLine(w2,clipped=False), updateRect)])]
  745. return updateRect
  746. def resize(self, event = None):
  747. """
  748. Sets scrollbar settings based on panel size and widgets inside.
  749. This is designed to always give the user more room than they actually need
  750. to see everything already created, so that they can scroll down or over
  751. to add more things.
  752. """
  753. neededSize = self.toPixels(self.getSize(), scaleOnly = True)
  754. visibleSize = self.GetClientSize()
  755. maxWidth = max(neededSize[0], visibleSize[0]) + visibleSize[0]
  756. maxHeight = max(neededSize[1], visibleSize[1]) + visibleSize[1]
  757. self.SetVirtualSize((maxWidth, maxHeight))
  758. self.SetScrollRate(StoryPanel.SCROLL_SPEED, StoryPanel.SCROLL_SPEED)
  759. self.visibleWidgets = None
  760. def serialize(self):
  761. """Returns a dictionary of state suitable for pickling."""
  762. state = { 'scale': self.scale, 'widgets': [], 'snapping': self.snapping }
  763. for widget in self.widgetDict.itervalues():
  764. state['widgets'].append(widget.serialize())
  765. return state
  766. def serialize_noprivate(self):
  767. """Returns a dictionary of state suitable for pickling without passage marked with a Twine.private tag."""
  768. state = { 'scale': self.scale, 'widgets': [], 'snapping': self.snapping }
  769. for widget in self.widgetDict.itervalues():
  770. if not any('Twine.private' in t for t in widget.passage.tags):
  771. state['widgets'].append(widget.serialize())
  772. return state
  773. def handleHoverStart(self, event):
  774. """Turns on hover tracking when mouse enters the frame."""
  775. self.trackinghover = True
  776. def handleHoverStop(self, event):
  777. """Turns off hover tracking when mouse leaves the frame."""
  778. self.trackinghover = False
  779. def tooltipShow(self):
  780. """ Show the tooltip, showing a text sample for text passages,
  781. and some image size info for image passages."""
  782. if self.tooltipplace is not None and self.trackinghover and not self.draggingWidgets:
  783. m = wx.GetMousePosition()
  784. p = self.tooltipplace.passage
  785. length = len(p.text)
  786. if p.isImage():
  787. mimeType = "unknown"
  788. mimeTypeRE = re.search(r"data:image/([^;]*);",p.text)
  789. if mimeTypeRE:
  790. mimeType = mimeTypeRE.group(1)
  791. # Including the data URI prefix in the byte count, just because.
  792. text = "Image type: " + mimeType + "\nSize: "+ str(len(p.text)/1024)+" KB"
  793. else:
  794. text = "Title: " + p.title + "\n" + ("Tags: " + ", ".join(p.tags) + '\n\n' if p.tags else "")
  795. text += p.text[:840]
  796. if length >= 840:
  797. text += "..."
  798. # Don't show a tooltip for a 0-length passage
  799. if length > 0:
  800. self.tooltipobj = wx.TipWindow(self, text, min(240, max(160,length/2)), wx.Rect(m[0],m[1],1,1))
  801. def handleHover(self, event):
  802. self.updateVisableRectsAndReturnUpdateRegion()
  803. if self.trackinghover and not self.draggingWidgets and not self.draggingMarquee:
  804. position = self.toLogical(event.GetPosition())
  805. for widget in self.visibleWidgets:
  806. if widget.getLogicalRect().Contains(position):
  807. if widget is not self.tooltipplace:
  808. # Stop current timer
  809. if self.tooltiptimer.IsRunning():
  810. self.tooltiptimer.Stop()
  811. self.tooltiptimer.Start(800, wx.TIMER_ONE_SHOT)
  812. self.tooltipplace = widget
  813. if self.tooltipobj:
  814. if isinstance(self.tooltipobj, wx.TipWindow):
  815. try:
  816. self.tooltipobj.Close()
  817. except:
  818. pass
  819. self.tooltipobj = None
  820. return
  821. self.tooltiptimer.Stop()
  822. self.tooltipplace = None
  823. if self.tooltipobj:
  824. if isinstance(self.tooltipobj, wx.TipWindow):
  825. try:
  826. self.tooltipobj.Close()
  827. except:
  828. pass
  829. self.tooltipobj = None
  830. def getHeader(self):
  831. """Returns the current selected target header for this Story Panel."""
  832. return self.parent.getHeader()
  833. INSET = (10, 10)
  834. ARROWHEAD_THRESHOLD = 0.5 # won't be drawn below this zoom level
  835. FIRST_CSS = """/* Your story will use the CSS in this passage to style the page.
  836. Give this passage more tags, and it will only affect passages with those tags.
  837. Example selectors: */
  838. body {
  839. \t/* This affects the entire page */
  840. \t
  841. \t
  842. }
  843. .passage {
  844. \t/* This only affects passages */
  845. \t
  846. \t
  847. }
  848. .passage a {
  849. \t/* This affects passage links */
  850. \t
  851. \t
  852. }
  853. .passage a:hover {
  854. \t/* This affects links while the cursor is over them */
  855. \t
  856. \t
  857. }"""
  858. BACKGROUND_COLOR = '#555753'
  859. FLAT_BG_COLOR = '#c6c6c6'
  860. MARQUEE_ALPHA = 32 # out of 256
  861. SCROLL_SPEED = 25
  862. EXTRA_SPACE = 200
  863. GRID_SPACING = 140
  864. CLIPBOARD_FORMAT = 'TwinePassages'
  865. UNDO_LIMIT = 10
  866. # context menu
  867. class StoryPanelContext(wx.Menu):
  868. def __init__(self, parent, pos):
  869. wx.Menu.__init__(self)
  870. self.parent = parent
  871. self.pos = pos
  872. if self.parent.parent.menus.IsEnabled(wx.ID_PASTE):
  873. pastePassage = wx.MenuItem(self, wx.NewId(), 'Paste Passage Here')
  874. self.AppendItem(pastePassage)
  875. self.Bind(wx.EVT_MENU, lambda e: self.parent.pasteWidgets(self.getPos()), id = pastePassage.GetId())
  876. newPassage = wx.MenuItem(self, wx.NewId(), 'New Passage Here')
  877. self.AppendItem(newPassage)
  878. self.Bind(wx.EVT_MENU, self.newWidget, id = newPassage.GetId())
  879. self.AppendSeparator()
  880. newPassage = wx.MenuItem(self, wx.NewId(), 'New Stylesheet Here')
  881. self.AppendItem(newPassage)
  882. self.Bind(wx.EVT_MENU, lambda e: self.newWidget(e, text = StoryPanel.FIRST_CSS, tags = ['stylesheet']), id = newPassage.GetId())
  883. newPassage = wx.MenuItem(self, wx.NewId(), 'New Script Here')
  884. self.AppendItem(newPassage)
  885. self.Bind(wx.EVT_MENU, lambda e: self.newWidget(e, tags = ['script']), id = newPassage.GetId())
  886. newPassage = wx.MenuItem(self, wx.NewId(), 'New Annotation Here')
  887. self.AppendItem(newPassage)
  888. self.Bind(wx.EVT_MENU, lambda e: self.newWidget(e, tags = ['annotation']), id = newPassage.GetId())
  889. def getPos(self):
  890. pos = self.pos
  891. offset = self.parent.toPixels((PassageWidget.SIZE / 2, 0), scaleOnly = True)
  892. pos.x = pos.x - offset[0]
  893. pos.y = pos.y - offset[0]
  894. return pos
  895. def newWidget(self, event, text = '', tags = ()):
  896. self.parent.newWidget(pos = self.getPos(), text = text, tags = tags)
  897. # drag and drop listener
  898. class StoryPanelDropTarget(wx.PyDropTarget):
  899. def __init__(self, panel):
  900. wx.PyDropTarget.__init__(self)
  901. self.panel = panel
  902. self.data = wx.DataObjectComposite()
  903. self.filedrop = wx.FileDataObject()
  904. self.textdrop = wx.TextDataObject()
  905. self.data.Add(self.filedrop,False)
  906. self.data.Add(self.textdrop,False)
  907. self.SetDataObject(self.data)
  908. def OnData(self, x, y, d):
  909. if self.GetData():
  910. type = self.data.GetReceivedFormat().GetType()
  911. if type in [wx.DF_UNICODETEXT, wx.DF_TEXT]:
  912. # add the new widget
  913. # Check for invalid characters, or non-unique titles
  914. text = self.textdrop.GetText()
  915. if "|" in text:
  916. return None
  917. else:
  918. if self.panel.passageExists(text):
  919. return None
  920. self.panel.newWidget(title = text, pos = (x, y))
  921. # update the source text with a link
  922. # this is set by PassageFrame.prepDrag()
  923. # (note: if text is dragged from outside Twine into it,
  924. # then it won't be set for the destination.)
  925. if self.panel.textDragSource:
  926. self.panel.textDragSource.linkSelection()
  927. # Cancel the deletion of the source text by returning None
  928. return None
  929. elif type == wx.DF_FILENAME:
  930. imageRegex = r'\.(?:jpe?g|png|gif|webp|svg)$'
  931. files = self.filedrop.GetFilenames()
  932. # Check if dropped files contains multiple images,
  933. # so the correct dialogs are displayed
  934. imagesImported = 0
  935. multipleImages = len([re.search(imageRegex, file) for file in files]) > 1
  936. for file in files:
  937. fname = file.lower()
  938. # Open a file if it's .tws
  939. if fname.endswith(".tws"):
  940. self.panel.app.open(file)
  941. # Import a file if it's HTML, .tw or .twee
  942. elif fname.endswith(".twee") or fname.endswith(".tw"):
  943. self.panel.parent.importSource(file)
  944. elif fname.endswith(".html") or fname.endswith(".htm"):
  945. self.panel.parent.importHtml(file)
  946. elif re.search(imageRegex, fname):
  947. text, title = self.panel.parent.openFileAsBase64(fname)
  948. imagesImported += 1 if self.panel.parent.finishImportImage(text, title, showdialog = not multipleImages) else 0
  949. if imagesImported > 1:
  950. dialog = wx.MessageDialog(self.panel.parent, 'Multiple image files imported successfully.', 'Images added', \
  951. wx.ICON_INFORMATION | wx.OK)
  952. dialog.ShowModal()
  953. return d