UI/Previewer.py

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

eric ide

mercurial