UI/Previewer.py

changeset 2424
8fe1fdc174ab
child 2500
a06292298f9a
diff -r bf4e888d72ab -r 8fe1fdc174ab UI/Previewer.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/UI/Previewer.py	Sun Feb 17 18:23:01 2013 +0100
@@ -0,0 +1,517 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2013 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a previewer widget for HTML, Markdown and ReST files.
+"""
+
+import os
+import threading
+import re
+
+from PyQt4.QtCore import pyqtSlot, pyqtSignal, Qt, QTimer, QSize, QUrl, QThread
+from PyQt4.QtGui import QWidget
+from PyQt4.QtWebKit import QWebPage
+
+from E5Gui.E5Application import e5App
+
+from .Ui_Previewer import Ui_Previewer
+
+import Preferences
+import Utilities
+
+
+class Previewer(QWidget, Ui_Previewer):
+    """
+    Class implementing a previewer widget for HTML, Markdown and ReST files.
+    """
+    def __init__(self, viewmanager, splitter, parent=None):
+        """
+        Constructor
+        
+        @param viewmanager reference to the viewmanager object (ViewManager)
+        @param splitter reference to the embedding splitter (QSplitter)
+        @param parent reference to the parent widget (QWidget)
+        """
+        super().__init__(parent)
+        self.setupUi(self)
+        
+        self.__vm = viewmanager
+        self.__splitter = splitter
+        
+        self.__firstShow = True
+        
+        self.previewView.page().setLinkDelegationPolicy(QWebPage.DelegateAllLinks)
+        
+        # Don't update too often because the UI might become sluggish
+        self.__typingTimer = QTimer()
+        self.__typingTimer.setInterval(500)     # 500ms
+        self.__typingTimer.timeout.connect(self.__runProcessingThread)
+        
+        self.__scrollBarPositions = {}
+        self.__vScrollBarAtEnd = {}
+        self.__hScrollBarAtEnd = {}
+        
+        self.__processingThread = PreviewProcessingThread()
+        self.__processingThread.htmlReady.connect(self.__setHtml)
+
+        self.__previewedPath = None
+        
+        self.__vm.editorChangedEd.connect(self.__editorChanged)
+        self.__vm.editorLanguageChanged.connect(self.__editorLanguageChanged)
+        self.__vm.editorTextChanged.connect(self.__editorTextChanged)
+
+        self.__vm.previewStateChanged.connect(self.__previewStateChanged)
+        
+        self.__splitter.splitterMoved.connect(self.__splitterMoved)
+        
+        self.hide()
+    
+    def show(self):
+        """
+        Public method to show the preview widget.
+        """
+        super().show()
+        if self.__firstShow:
+            self.__splitter.restoreState(Preferences.getUI("PreviewSplitterState"))
+            self.jsCheckBox.setChecked(Preferences.getUI("ShowFilePreviewJS"))
+            self.ssiCheckBox.setChecked(Preferences.getUI("ShowFilePreviewSSI"))
+            self.__firstShow = False
+        self.__typingTimer.start()
+    
+    def hide(self):
+        """
+        Public method to hide the preview widget.
+        """
+        super().hide()
+        self.__typingTimer.stop()
+    
+    def shutdown(self):
+        """
+        Public method to perform shutdown actions.
+        """
+        self.__typingTimer.stop()
+        self.__processingThread.wait()
+    
+    def __splitterMoved(self):
+        """
+        Private slot to handle the movement of the embedding splitter's handle.
+        """
+        state = self.__splitter.saveState()
+        Preferences.setUI("PreviewSplitterState", state)
+    
+    @pyqtSlot(bool)
+    def on_jsCheckBox_clicked(self, checked):
+        """
+        Private slot to enable/disable JavaScript.
+        
+        @param checked state of the checkbox (boolean)
+        """
+        Preferences.setUI("ShowFilePreviewJS", checked)
+        self.__setJavaScriptEnabled(checked)
+    
+    def __setJavaScriptEnabled(self, enable):
+        """
+        Private method to enable/disable JavaScript.
+        
+        @param enable flag indicating the enable state (boolean)
+        """
+        self.jsCheckBox.setChecked(enable)
+        
+        settings = self.previewView.settings()
+        settings.setAttribute(settings.JavascriptEnabled, enable)
+        
+        self.__runProcessingThread()
+    
+    @pyqtSlot(bool)
+    def on_ssiCheckBox_clicked(self, checked):
+        """
+        Private slot to enable/disable SSI.
+        
+        @param checked state of the checkbox (boolean)
+        """
+        Preferences.setUI("ShowFilePreviewSSI", checked)
+        self.__runProcessingThread()
+    
+    def __editorChanged(self, editor):
+        """
+        Private slot to handle a change of the current editor.
+        
+        @param editor reference to the editor (Editor)
+        """
+        if editor is None:
+            self.hide()
+            return
+        
+        if Preferences.getUI("ShowFilePreview") and self.__isPreviewable(editor):
+            self.show()
+            self.__runProcessingThread()
+        else:
+            self.hide()
+    
+    def __editorLanguageChanged(self, editor):
+        """
+        Private slot to handle a change of the current editor's language.
+        
+        @param editor reference to the editor (Editor)
+        """
+        self.__editorChanged(editor)
+    
+    def __editorTextChanged(self, editor):
+        """
+        Private slot to handle changes of an editor's text.
+        
+        @param editor reference to the editor (Editor)
+        """
+        if self.isVisible():
+            self.__typingTimer.stop()
+            self.__typingTimer.start()
+    
+    def __previewStateChanged(self, on):
+        """
+        Public slot to toggle the display of the preview.
+        
+        @param on flag indicating to show a preview (boolean)
+        """
+        editor = self.__vm.activeWindow()
+        if on and self.__isPreviewable(editor):
+            self.show()
+        else:
+            self.hide()
+    
+    def __isPreviewable(self, editor):
+        """
+        Private method to check, if a preview can be shown for the given editor.
+        
+        @param editor reference to an editor (Editor)
+        @return flag indicating if a preview can be shown (boolean)
+        """
+        if editor.getFileName() is not None:
+            extension = os.path.normcase(os.path.splitext(editor.getFileName())[1][1:])
+            return extension in \
+                Preferences.getEditor("PreviewHtmlFileNameExtensions") + \
+                Preferences.getEditor("PreviewMarkdownFileNameExtensions") + \
+                Preferences.getEditor("PreviewRestFileNameExtensions")
+        elif editor.getLanguage() == "HTML":
+            return True
+        else:
+            return False
+    
+    def __runProcessingThread(self):
+        """
+        Private slot to schedule the processing of the current editor's text.
+        """
+        self.__typingTimer.stop()
+        
+        editor = self.__vm.activeWindow()
+        if editor is not None:
+            fn = editor.getFileName()
+            
+            if fn:
+                extension = os.path.normcase(os.path.splitext(fn)[1][1:])
+            else:
+                extension = ""
+            if extension in \
+                Preferences.getEditor("PreviewHtmlFileNameExtensions") or \
+               editor.getLanguage() == "HTML":
+                language = "HTML"
+            elif extension in \
+                Preferences.getEditor("PreviewMarkdownFileNameExtensions"):
+                language = "Markdown"
+            elif extension in \
+                Preferences.getEditor("PreviewRestFileNameExtensions"):
+                language = "ReST"
+            else:
+                self.__setHtml(fn,
+                    self.trUtf8("<p>No preview available for this type of file.</p>"))
+                return
+            
+            if fn:
+                project = e5App().getObject("Project")
+                if project.isProjectFile(fn):
+                    rootPath = project.getProjectPath()
+                else:
+                    rootPath = os.path.dirname(os.path.abspath(fn))
+            else:
+                rootPath = ""
+            
+            self.__processingThread.process(
+                fn, language, editor.text(),
+                self.ssiCheckBox.isChecked(), rootPath)
+
+    def __setHtml(self, filePath, html):
+        """
+        Private method to set the HTML to the view and restore the scroll bars positions.
+        
+        @param filePath file path of the previewed editor (string)
+        @param html processed HTML text ready to be shown (string)
+        """
+        self.__saveScrollBarPositions()
+        self.__previewedPath = Utilities.normcasepath(
+            Utilities.fromNativeSeparators(filePath))
+        self.previewView.page().mainFrame().contentsSizeChanged.connect(
+            self.__restoreScrollBarPositions)
+        self.previewView.setHtml(html, baseUrl=QUrl.fromLocalFile(filePath))
+    
+    @pyqtSlot(str)
+    def on_previewView_titleChanged(self, title):
+        """
+        Private slot to handle a change of the title.
+        
+        @param title new title (string)
+        """
+        if title:
+            self.titleLabel.setText(self.trUtf8("Preview - {0}").format(title))
+        else:
+            self.titleLabel.setText(self.trUtf8("Preview"))
+    
+    def __saveScrollBarPositions(self):
+        """
+        Private method to save scroll bar positions for a previewed editor.
+        """
+        frame = self.previewView.page().mainFrame()
+        if frame.contentsSize() == QSize(0, 0):
+            return # no valid data, nothing to save
+        
+        pos = frame.scrollPosition()
+        self.__scrollBarPositions[self.__previewedPath] = pos
+        self.__hScrollBarAtEnd[self.__previewedPath] = \
+            frame.scrollBarMaximum(Qt.Horizontal) == pos.x()
+        self.__vScrollBarAtEnd[self.__previewedPath] = \
+            frame.scrollBarMaximum(Qt.Vertical) == pos.y()
+
+    def __restoreScrollBarPositions(self):
+        """
+        Private method to restore scroll bar positions for a previewed editor.
+        """
+        try:
+            self.previewView.page().mainFrame().contentsSizeChanged.disconnect(
+                self.__restoreScrollBarPositions)
+        except TypeError:
+            # not connected, simply ignore it
+            pass
+        
+        if self.__previewedPath not in self.__scrollBarPositions:
+            return
+        
+        frame = self.previewView.page().mainFrame()
+        frame.setScrollPosition(self.__scrollBarPositions[self.__previewedPath])
+        
+        if self.__hScrollBarAtEnd[self.__previewedPath]:
+            frame.setScrollBarValue(Qt.Horizontal, frame.scrollBarMaximum(Qt.Horizontal))
+        
+        if self.__vScrollBarAtEnd[self.__previewedPath]:
+            frame.setScrollBarValue(Qt.Vertical, frame.scrollBarMaximum(Qt.Vertical))
+    
+    @pyqtSlot(QUrl)
+    def on_previewView_linkClicked(self, url):
+        """
+        Private slot handling the clicking of a link.
+        
+        @param url url of the clicked link (QUrl)
+        """
+        e5App().getObject("UserInterface").launchHelpViewer(url.toString())
+
+
+class PreviewProcessingThread(QThread):
+    """
+    Class implementing a thread to process some text into HTML usable by the 
+    previewer view.
+    
+    @signal htmlReady(str,str) emitted with the file name and processed HTML to signal
+            the availability of the processed HTML
+    """
+    htmlReady = pyqtSignal(str, str)
+    
+    def __init__(self, parent=None):
+        """
+        Constructor
+        
+        @param parent reference to the parent object (QObject)
+        """
+        super().__init__()
+        
+        self.__lock = threading.Lock()
+    
+    def process(self, filePath, language, text, ssiEnabled, rootPath):
+        """
+        Convert the given text to HTML.
+        
+        @param filePath file path of the text (string)
+        @param language language of the text (string)
+        @param text text to be processed (string)
+        @param ssiEnabled flag indicating to do some (limited) SSI processing (boolean)
+        @param rootPath root path to be used for SSI processing (str)
+        """
+        with self.__lock:
+            self.__filePath = filePath
+            self.__language = language
+            self.__text = text
+            self.__ssiEnabled = ssiEnabled
+            self.__rootPath = rootPath
+            self.__haveData = True
+            if not self.isRunning():
+                self.start(QThread.LowPriority)
+    
+    def run(self):
+        """
+        Thread function to convert the stored data.
+        """
+        while True:
+            # exits with break
+            with self.__lock:
+                filePath = self.__filePath
+                language = self.__language
+                text = self.__text
+                ssiEnabled = self.__ssiEnabled
+                rootPath = self.__rootPath
+                self.__haveData = False
+            
+            html = self.__getHtml(language, text, ssiEnabled, filePath, rootPath)
+            
+            with self.__lock:
+                if not self.__haveData:
+                    self.htmlReady.emit(filePath, html)
+                    break
+                # else - next iteration
+    
+    def __getHtml(self, language, text, ssiEnabled, filePath, rootPath):
+        """
+        Private method to process the given text depending upon the given language.
+        
+        @param language language of the text (string)
+        @param text to be processed (string)
+        @param ssiEnabled flag indicating to do some (limited) SSI processing (boolean)
+        @param filePath file path of the text (string)
+        @param rootPath root path to be used for SSI processing (str)
+        """
+        if language == "HTML":
+            if ssiEnabled:
+                return self.__processSSI(text, filePath, rootPath)
+            else:
+                return text
+        elif language == "Markdown":
+            return self.__convertMarkdown(text)
+        elif language == "ReST":
+            return self.__convertReST(text)
+        else:
+            return self.trUtf8("<p>No preview available for this type of file.</p>")
+    
+    def __processSSI(self, txt, filename, root):
+        """
+        Private method to process the given text for SSI statements.
+        
+        Note: Only a limited subset of SSI statements are supported.
+        
+        @param txt text to be processed (string)
+        @param filename name of the file associated with the given text (string)
+        @param root directory of the document root (string)
+        @return processed HTML (string)
+        """
+        if not filename:
+            return txt
+        
+        # SSI include
+        incRe = re.compile(
+            r"""<!--#include[ \t]+(virtual|file)=[\"']([^\"']+)[\"']\s*-->""",
+            re.IGNORECASE)
+        baseDir = os.path.dirname(os.path.abspath(filename))
+        docRoot = root if root != "" else baseDir
+        while True:
+            incMatch = incRe.search(txt)
+            if incMatch is None:
+                break
+            
+            if incMatch.group(1) == "virtual":
+                incFile = Utilities.normjoinpath(docRoot, incMatch.group(2))
+            elif incMatch.group(1) == "file":
+                incFile = Utilities.normjoinpath(baseDir, incMatch.group(2))
+            else:
+                incFile = ""
+            if os.path.exists(incFile):
+                try:
+                    f = open(incFile, "r")
+                    incTxt = f.read()
+                    f.close()
+                except (IOError, OSError):
+                    # remove SSI include
+                    incTxt = ""
+            else:
+                # remove SSI include
+                incTxt = ""
+            txt = txt[:incMatch.start(0)] + incTxt + txt[incMatch.end(0):]
+        
+        return txt
+    
+    def __convertReST(self, text):
+        """
+        Private method to convert ReST text into HTML.
+        
+        @param text text to be processed (string)
+        @return processed HTML (string)
+        """
+        try:
+            import docutils.core    # __IGNORE_EXCEPTION__ __IGNORE_WARNING__
+        except ImportError:
+            return self.trUtf8(
+                """<p>ReStructuredText preview requires the <b>python-docutils</b> """
+                """package.<br/>Install it with your package manager or see """
+                """<a href="http://pypi.python.org/pypi/docutils">this page.</a></p>""")
+        
+        return docutils.core.publish_string(text, writer_name='html').decode("utf-8")
+    
+    def __convertMarkdown(self, text):
+        """
+        Private method to convert Markdown text into HTML.
+        
+        @param text text to be processed (string)
+        @return processed HTML (string)
+        """
+        try:
+            import markdown     # __IGNORE_EXCEPTION__ __IGNORE_WARNING__
+        except ImportError:
+            return self.trUtf8(
+                """<p>Markdown preview requires the <b>python-markdown</b> """
+                """package.<br/>Install it with your package manager or see """
+                """<a href="http://pythonhosted.org/Markdown/install.html">"""
+                """installation instructions.</a></p>""")
+        
+        try:
+            import mdx_mathjax  # __IGNORE_EXCEPTION__ __IGNORE_WARNING__
+        except ImportError:
+            #mathjax doesn't require import statement if installed as extension
+            pass
+
+        extensions = ['fenced_code', 'nl2br']
+        
+        # version 2.0 supports only extension names, not instances
+        if markdown.version_info[0] > 2 or \
+           (markdown.version_info[0] == 2 and markdown.version_info[1] > 0):
+            
+            class _StrikeThroughExtension(markdown.Extension):
+                """
+                Class is placed here, because it depends on imported markdown,
+                and markdown import is lazy.
+                
+                (see
+                <a href="http://achinghead.com/python-markdown-adding-insert-delete.html">
+                this page for details</a>)
+                """
+                DEL_RE = r'(~~)(.*?)~~'
+                def extendMarkdown(self, md, md_globals):
+                    # Create the del pattern
+                    del_tag = markdown.inlinepatterns.SimpleTagPattern(self.DEL_RE, 'del')
+                    # Insert del pattern into markdown parser
+                    md.inlinePatterns.add('del', del_tag, '>not_strong')
+            
+            extensions.append(_StrikeThroughExtension())
+
+        try:
+            return markdown.markdown(text,  extensions + ['mathjax'])
+        except (ImportError, ValueError): 
+            # markdown raises ValueError or ImportError, depends on version
+            # It is not clear, how to distinguish missing mathjax from other errors.
+            # So keep going without mathjax.
+            return markdown.markdown(text, extensions)

eric ide

mercurial