UI/Previewer.py

changeset 3458
64bbac483843
parent 3190
a9a94491c4fd
child 3459
275cb00c83e2
equal deleted inserted replaced
3457:bfc38662e6fc 3458:64bbac483843
6 """ 6 """
7 Module implementing a previewer widget for HTML, Markdown and ReST files. 7 Module implementing a previewer widget for HTML, Markdown and ReST files.
8 """ 8 """
9 9
10 import os 10 import os
11 import threading
12 import re
13 11
14 from PyQt4.QtCore import pyqtSlot, pyqtSignal, Qt, QTimer, QSize, QUrl, QThread 12 from PyQt4.QtCore import QTimer
15 from PyQt4.QtGui import QWidget 13 from PyQt4.QtGui import QStackedWidget
16 from PyQt4.QtWebKit import QWebPage
17
18 from E5Gui.E5Application import e5App
19
20 from .Ui_Previewer import Ui_Previewer
21 14
22 import Preferences 15 import Preferences
23 import Utilities
24 16
25 17
26 class Previewer(QWidget, Ui_Previewer): 18 class Previewer(QStackedWidget):
27 """ 19 """
28 Class implementing a previewer widget for HTML, Markdown and ReST files. 20 Class implementing a previewer widget containing a stack of
21 specialized previewers.
29 """ 22 """
30 def __init__(self, viewmanager, splitter, parent=None): 23 def __init__(self, viewmanager, splitter, parent=None):
31 """ 24 """
32 Constructor 25 Constructor
33 26
34 @param viewmanager reference to the viewmanager object (ViewManager) 27 @param viewmanager reference to the viewmanager object (ViewManager)
35 @param splitter reference to the embedding splitter (QSplitter) 28 @param splitter reference to the embedding splitter (QSplitter)
36 @param parent reference to the parent widget (QWidget) 29 @param parent reference to the parent widget (QWidget)
37 """ 30 """
38 super().__init__(parent) 31 super().__init__(parent)
39 self.setupUi(self)
40 32
41 self.__vm = viewmanager 33 self.__vm = viewmanager
42 self.__splitter = splitter 34 self.__splitter = splitter
43 35
44 self.__firstShow = True 36 self.__firstShow = True
45 37
46 self.previewView.page().setLinkDelegationPolicy( 38 self.__htmlPreviewer = None
47 QWebPage.DelegateAllLinks)
48 39
49 # Don't update too often because the UI might become sluggish 40 # Don't update too often because the UI might become sluggish
50 self.__typingTimer = QTimer() 41 self.__typingTimer = QTimer()
51 self.__typingTimer.setInterval(500) # 500ms 42 self.__typingTimer.setInterval(500) # 500ms
52 self.__typingTimer.timeout.connect(self.__runProcessingThread) 43 self.__typingTimer.timeout.connect(self.__processEditor)
53
54 self.__scrollBarPositions = {}
55 self.__vScrollBarAtEnd = {}
56 self.__hScrollBarAtEnd = {}
57
58 self.__processingThread = PreviewProcessingThread()
59 self.__processingThread.htmlReady.connect(self.__setHtml)
60
61 self.__previewedPath = None
62 44
63 self.__vm.editorChangedEd.connect(self.__editorChanged) 45 self.__vm.editorChangedEd.connect(self.__editorChanged)
64 self.__vm.editorLanguageChanged.connect(self.__editorLanguageChanged) 46 self.__vm.editorLanguageChanged.connect(self.__editorLanguageChanged)
65 self.__vm.editorTextChanged.connect(self.__editorTextChanged) 47 self.__vm.editorTextChanged.connect(self.__editorTextChanged)
66 48
76 """ 58 """
77 super().show() 59 super().show()
78 if self.__firstShow: 60 if self.__firstShow:
79 self.__splitter.restoreState( 61 self.__splitter.restoreState(
80 Preferences.getUI("PreviewSplitterState")) 62 Preferences.getUI("PreviewSplitterState"))
81 self.jsCheckBox.setChecked(
82 Preferences.getUI("ShowFilePreviewJS"))
83 self.ssiCheckBox.setChecked(
84 Preferences.getUI("ShowFilePreviewSSI"))
85 self.__firstShow = False 63 self.__firstShow = False
86 self.__typingTimer.start() 64 self.__typingTimer.start()
87 65
88 def hide(self): 66 def hide(self):
89 """ 67 """
95 def shutdown(self): 73 def shutdown(self):
96 """ 74 """
97 Public method to perform shutdown actions. 75 Public method to perform shutdown actions.
98 """ 76 """
99 self.__typingTimer.stop() 77 self.__typingTimer.stop()
100 self.__processingThread.wait() 78 self.__htmlPreviewer and self.__htmlPreviewer.shutdown()
101 79
102 def __splitterMoved(self): 80 def __splitterMoved(self):
103 """ 81 """
104 Private slot to handle the movement of the embedding splitter's handle. 82 Private slot to handle the movement of the embedding splitter's handle.
105 """ 83 """
106 state = self.__splitter.saveState() 84 state = self.__splitter.saveState()
107 Preferences.setUI("PreviewSplitterState", state) 85 Preferences.setUI("PreviewSplitterState", state)
108
109 @pyqtSlot(bool)
110 def on_jsCheckBox_clicked(self, checked):
111 """
112 Private slot to enable/disable JavaScript.
113
114 @param checked state of the checkbox (boolean)
115 """
116 Preferences.setUI("ShowFilePreviewJS", checked)
117 self.__setJavaScriptEnabled(checked)
118
119 def __setJavaScriptEnabled(self, enable):
120 """
121 Private method to enable/disable JavaScript.
122
123 @param enable flag indicating the enable state (boolean)
124 """
125 self.jsCheckBox.setChecked(enable)
126
127 settings = self.previewView.settings()
128 settings.setAttribute(settings.JavascriptEnabled, enable)
129
130 self.__runProcessingThread()
131
132 @pyqtSlot(bool)
133 def on_ssiCheckBox_clicked(self, checked):
134 """
135 Private slot to enable/disable SSI.
136
137 @param checked state of the checkbox (boolean)
138 """
139 Preferences.setUI("ShowFilePreviewSSI", checked)
140 self.__runProcessingThread()
141 86
142 def __editorChanged(self, editor): 87 def __editorChanged(self, editor):
143 """ 88 """
144 Private slot to handle a change of the current editor. 89 Private slot to handle a change of the current editor.
145 90
150 return 95 return
151 96
152 if Preferences.getUI("ShowFilePreview") and \ 97 if Preferences.getUI("ShowFilePreview") and \
153 self.__isPreviewable(editor): 98 self.__isPreviewable(editor):
154 self.show() 99 self.show()
155 self.__runProcessingThread() 100 self.__processEditor()
156 else: 101 else:
157 self.hide() 102 self.hide()
158 103
159 def __editorLanguageChanged(self, editor): 104 def __editorLanguageChanged(self, editor):
160 """ 105 """
206 elif editor.getLanguage() == "HTML": 151 elif editor.getLanguage() == "HTML":
207 return True 152 return True
208 153
209 return False 154 return False
210 155
211 def __runProcessingThread(self): 156 def __processEditor(self):
212 """ 157 """
213 Private slot to schedule the processing of the current editor's text. 158 Private slot to schedule the processing of the current editor's text.
214 """ 159 """
215 self.__typingTimer.stop() 160 self.__typingTimer.stop()
216 161
230 Preferences.getEditor("PreviewMarkdownFileNameExtensions"): 175 Preferences.getEditor("PreviewMarkdownFileNameExtensions"):
231 language = "Markdown" 176 language = "Markdown"
232 elif extension in \ 177 elif extension in \
233 Preferences.getEditor("PreviewRestFileNameExtensions"): 178 Preferences.getEditor("PreviewRestFileNameExtensions"):
234 language = "ReST" 179 language = "ReST"
180 elif extension in \
181 Preferences.getEditor("PreviewQssFileNameExtensions"):
182 language = "QSS"
235 else: 183 else:
236 self.__setHtml(fn, self.tr( 184 language = ""
237 "<p>No preview available for this type of file.</p>"))
238 return
239 185
240 if fn: 186 if language in ["HTML", "Markdown", "ReST"]:
241 project = e5App().getObject("Project") 187 if self.__htmlPreviewer is None:
242 if project.isProjectFile(fn): 188 from .Previewers.PreviewerHTML import PreviewerHTML
243 rootPath = project.getProjectPath() 189 self.__htmlPreviewer = PreviewerHTML()
244 else: 190 self.addWidget(self.__htmlPreviewer)
245 rootPath = os.path.dirname(os.path.abspath(fn)) 191 self.setCurrentWidget(self.__htmlPreviewer)
246 else: 192 self.__htmlPreviewer.processEditor(editor)
247 rootPath = "" 193 elif language == "QSS":
248 194 # TODO: add QSS
249 self.__processingThread.process( 195 pass
250 fn, language, editor.text(),
251 self.ssiCheckBox.isChecked(), rootPath)
252
253 def __setHtml(self, filePath, html):
254 """
255 Private method to set the HTML to the view and restore the scroll bars
256 positions.
257
258 @param filePath file path of the previewed editor (string)
259 @param html processed HTML text ready to be shown (string)
260 """
261 self.__saveScrollBarPositions()
262 self.__previewedPath = Utilities.normcasepath(
263 Utilities.fromNativeSeparators(filePath))
264 self.previewView.page().mainFrame().contentsSizeChanged.connect(
265 self.__restoreScrollBarPositions)
266 self.previewView.setHtml(html, baseUrl=QUrl.fromLocalFile(filePath))
267
268 @pyqtSlot(str)
269 def on_previewView_titleChanged(self, title):
270 """
271 Private slot to handle a change of the title.
272
273 @param title new title (string)
274 """
275 if title:
276 self.titleLabel.setText(self.tr("Preview - {0}").format(title))
277 else:
278 self.titleLabel.setText(self.tr("Preview"))
279
280 def __saveScrollBarPositions(self):
281 """
282 Private method to save scroll bar positions for a previewed editor.
283 """
284 frame = self.previewView.page().mainFrame()
285 if frame.contentsSize() == QSize(0, 0):
286 return # no valid data, nothing to save
287
288 pos = frame.scrollPosition()
289 self.__scrollBarPositions[self.__previewedPath] = pos
290 self.__hScrollBarAtEnd[self.__previewedPath] = \
291 frame.scrollBarMaximum(Qt.Horizontal) == pos.x()
292 self.__vScrollBarAtEnd[self.__previewedPath] = \
293 frame.scrollBarMaximum(Qt.Vertical) == pos.y()
294
295 def __restoreScrollBarPositions(self):
296 """
297 Private method to restore scroll bar positions for a previewed editor.
298 """
299 try:
300 self.previewView.page().mainFrame().contentsSizeChanged.disconnect(
301 self.__restoreScrollBarPositions)
302 except TypeError:
303 # not connected, simply ignore it
304 pass
305
306 if self.__previewedPath not in self.__scrollBarPositions:
307 return
308
309 frame = self.previewView.page().mainFrame()
310 frame.setScrollPosition(
311 self.__scrollBarPositions[self.__previewedPath])
312
313 if self.__hScrollBarAtEnd[self.__previewedPath]:
314 frame.setScrollBarValue(
315 Qt.Horizontal, frame.scrollBarMaximum(Qt.Horizontal))
316
317 if self.__vScrollBarAtEnd[self.__previewedPath]:
318 frame.setScrollBarValue(
319 Qt.Vertical, frame.scrollBarMaximum(Qt.Vertical))
320
321 @pyqtSlot(QUrl)
322 def on_previewView_linkClicked(self, url):
323 """
324 Private slot handling the clicking of a link.
325
326 @param url url of the clicked link (QUrl)
327 """
328 e5App().getObject("UserInterface").launchHelpViewer(url.toString())
329
330
331 class PreviewProcessingThread(QThread):
332 """
333 Class implementing a thread to process some text into HTML usable by the
334 previewer view.
335
336 @signal htmlReady(str,str) emitted with the file name and processed HTML
337 to signal the availability of the processed HTML
338 """
339 htmlReady = pyqtSignal(str, str)
340
341 def __init__(self, parent=None):
342 """
343 Constructor
344
345 @param parent reference to the parent object (QObject)
346 """
347 super().__init__()
348
349 self.__lock = threading.Lock()
350
351 def process(self, filePath, language, text, ssiEnabled, rootPath):
352 """
353 Convert the given text to HTML.
354
355 @param filePath file path of the text (string)
356 @param language language of the text (string)
357 @param text text to be processed (string)
358 @param ssiEnabled flag indicating to do some (limited) SSI processing
359 (boolean)
360 @param rootPath root path to be used for SSI processing (str)
361 """
362 with self.__lock:
363 self.__filePath = filePath
364 self.__language = language
365 self.__text = text
366 self.__ssiEnabled = ssiEnabled
367 self.__rootPath = rootPath
368 self.__haveData = True
369 if not self.isRunning():
370 self.start(QThread.LowPriority)
371
372 def run(self):
373 """
374 Thread function to convert the stored data.
375 """
376 while True:
377 # exits with break
378 with self.__lock:
379 filePath = self.__filePath
380 language = self.__language
381 text = self.__text
382 ssiEnabled = self.__ssiEnabled
383 rootPath = self.__rootPath
384 self.__haveData = False
385
386 html = self.__getHtml(language, text, ssiEnabled, filePath,
387 rootPath)
388
389 with self.__lock:
390 if not self.__haveData:
391 self.htmlReady.emit(filePath, html)
392 break
393 # else - next iteration
394
395 def __getHtml(self, language, text, ssiEnabled, filePath, rootPath):
396 """
397 Private method to process the given text depending upon the given
398 language.
399
400 @param language language of the text (string)
401 @param text to be processed (string)
402 @param ssiEnabled flag indicating to do some (limited) SSI processing
403 (boolean)
404 @param filePath file path of the text (string)
405 @param rootPath root path to be used for SSI processing (str)
406 @return processed HTML text (string)
407 """
408 if language == "HTML":
409 if ssiEnabled:
410 return self.__processSSI(text, filePath, rootPath)
411 else:
412 return text
413 elif language == "Markdown":
414 return self.__convertMarkdown(text)
415 elif language == "ReST":
416 return self.__convertReST(text)
417 else:
418 return self.tr(
419 "<p>No preview available for this type of file.</p>")
420
421 def __processSSI(self, txt, filename, root):
422 """
423 Private method to process the given text for SSI statements.
424
425 Note: Only a limited subset of SSI statements are supported.
426
427 @param txt text to be processed (string)
428 @param filename name of the file associated with the given text
429 (string)
430 @param root directory of the document root (string)
431 @return processed HTML (string)
432 """
433 if not filename:
434 return txt
435
436 # SSI include
437 incRe = re.compile(
438 r"""<!--#include[ \t]+(virtual|file)=[\"']([^\"']+)[\"']\s*-->""",
439 re.IGNORECASE)
440 baseDir = os.path.dirname(os.path.abspath(filename))
441 docRoot = root if root != "" else baseDir
442 while True:
443 incMatch = incRe.search(txt)
444 if incMatch is None:
445 break
446
447 if incMatch.group(1) == "virtual":
448 incFile = Utilities.normjoinpath(docRoot, incMatch.group(2))
449 elif incMatch.group(1) == "file":
450 incFile = Utilities.normjoinpath(baseDir, incMatch.group(2))
451 else:
452 incFile = ""
453 if os.path.exists(incFile):
454 try:
455 f = open(incFile, "r")
456 incTxt = f.read()
457 f.close()
458 except (IOError, OSError):
459 # remove SSI include
460 incTxt = ""
461 else:
462 # remove SSI include
463 incTxt = ""
464 txt = txt[:incMatch.start(0)] + incTxt + txt[incMatch.end(0):]
465
466 return txt
467
468 def __convertReST(self, text):
469 """
470 Private method to convert ReST text into HTML.
471
472 @param text text to be processed (string)
473 @return processed HTML (string)
474 """
475 try:
476 import docutils.core # __IGNORE_EXCEPTION__ __IGNORE_WARNING__
477 except ImportError:
478 return self.tr(
479 """<p>ReStructuredText preview requires the"""
480 """ <b>python-docutils</b> package.<br/>Install it with"""
481 """ your package manager or see"""
482 """ <a href="http://pypi.python.org/pypi/docutils">"""
483 """this page.</a></p>""")
484
485 return docutils.core.publish_string(text, writer_name='html')\
486 .decode("utf-8")
487
488 def __convertMarkdown(self, text):
489 """
490 Private method to convert Markdown text into HTML.
491
492 @param text text to be processed (string)
493 @return processed HTML (string)
494 """
495 try:
496 import markdown # __IGNORE_EXCEPTION__ __IGNORE_WARNING__
497 except ImportError:
498 return self.tr(
499 """<p>Markdown preview requires the <b>python-markdown</b> """
500 """package.<br/>Install it with your package manager or see """
501 """<a href="http://pythonhosted.org/Markdown/install.html">"""
502 """installation instructions.</a></p>""")
503
504 try:
505 import mdx_mathjax # __IGNORE_EXCEPTION__ __IGNORE_WARNING__
506 except ImportError:
507 #mathjax doesn't require import statement if installed as extension
508 pass
509
510 extensions = ['fenced_code', 'nl2br', 'extra']
511
512 # version 2.0 supports only extension names, not instances
513 if markdown.version_info[0] > 2 or \
514 (markdown.version_info[0] == 2 and
515 markdown.version_info[1] > 0):
516 class _StrikeThroughExtension(markdown.Extension):
517 """
518 Class is placed here, because it depends on imported markdown,
519 and markdown import is lazy.
520
521 (see http://achinghead.com/
522 python-markdown-adding-insert-delete.html this page for
523 details)
524 """
525 DEL_RE = r'(~~)(.*?)~~'
526
527 def extendMarkdown(self, md, md_globals):
528 # Create the del pattern
529 del_tag = markdown.inlinepatterns.SimpleTagPattern(
530 self.DEL_RE, 'del')
531 # Insert del pattern into markdown parser
532 md.inlinePatterns.add('del', del_tag, '>not_strong')
533
534 extensions.append(_StrikeThroughExtension())
535
536 try:
537 return markdown.markdown(text, extensions + ['mathjax'])
538 except (ImportError, ValueError):
539 # markdown raises ValueError or ImportError, depends on version
540 # It is not clear, how to distinguish missing mathjax from other
541 # errors. So keep going without mathjax.
542 return markdown.markdown(text, extensions)

eric ide

mercurial