Ejemplo n.º 1
0
    def test_16(self):
        for _ in self.singleThreadOnly:
            rl = RunLatest(_, True)

            # Start a job.
            q1a = Queue()
            q1b = Queue()

            def f1():
                q1b.put(None)
                q1a.get()

            em1 = Emitter('em1 should never be called by {}'.format(_),
                          self.assertEqual)
            future1 = rl.start(em1.g, f1)
            q1b.get()
            self.assertEqual(future1.state, Future.STATE_RUNNING)

            # Start another job, canceling the previous job while it's running.
            em2 = Emitter()
            rl.start(em2.g, lambda: None)
            with WaitForSignal(em2.bing, 1000):
                q1a.put(None)

            rl.terminate()
Ejemplo n.º 2
0
    def test_15(self):
        for _ in self.singleThreadOnly:
            rl = RunLatest(_)

            # Start a job, keeping it running.
            q1a = Queue()
            q1b = Queue()

            def f1():
                q1b.put(None)
                q1a.get()

            em1 = Emitter()
            future1 = rl.start(em1.g, f1)
            q1b.get()
            self.assertEqual(future1.state, Future.STATE_RUNNING)

            # Start two more. The first should not run; if it does, it raises
            # an exception.
            def f2():
                raise TypeError

            rl.start(None, f2)
            em3 = Emitter()
            rl.start(em3.g, lambda: None)

            with WaitForSignal(em3.bing, 1000):
                q1a.put(None)

            rl.terminate()
Ejemplo n.º 3
0
    def test_15(self):
        for _ in self.singleThreadOnly:
            rl = RunLatest(_)

            # Start a job, keeping it running.
            q1a = Queue()
            q1b = Queue()

            def f1():
                q1b.put(None)
                q1a.get()
            em1 = Emitter()
            future1 = rl.start(em1.g, f1)
            q1b.get()
            self.assertEqual(future1.state, Future.STATE_RUNNING)

            # Start two more. The first should not run; if it does, it raises
            # an exception.
            def f2():
                raise TypeError
            rl.start(None, f2)
            em3 = Emitter()
            rl.start(em3.g, lambda: None)

            with WaitForSignal(em3.bing, 1000):
                q1a.put(None)

            rl.terminate()
Ejemplo n.º 4
0
    def test_16(self):
        for _ in self.singleThreadOnly:
            rl = RunLatest(_, True)

            # Start a job.
            q1a = Queue()
            q1b = Queue()

            def f1():
                q1b.put(None)
                q1a.get()
            em1 = Emitter('em1 should never be called by {}'.format(_),
                          self.assertEqual)
            future1 = rl.start(em1.g, f1)
            q1b.get()
            self.assertEqual(future1.state, Future.STATE_RUNNING)

            # Start another job, canceling the previous job while it's running.
            em2 = Emitter()
            rl.start(em2.g, lambda: None)
            with WaitForSignal(em2.bing, 1000):
                q1a.put(None)

            rl.terminate()
Ejemplo n.º 5
0
class PreviewDock(DockWidget):
    """GUI and implementation
    """
    # Emitted when this window is closed.
    closed = pyqtSignal()

    def __init__(self):
        DockWidget.__init__(self, core.mainWindow(), "Previe&w",
                            QIcon(':/enkiicons/internet.png'), "Alt+W")

        self._widget = self._createWidget()
        # Don't need to schedule document processing; a call to show() does.

        self._loadTemplates()
        self._widget.cbTemplate.currentIndexChanged.connect(
            self._onCurrentTemplateChanged)  # Disconnected.

        # When quitting this program, don't rebuild when closing all open
        # documents. This can take a long time, particularly if a some of the
        # documents are associated with a Sphinx project.
        self._programRunning = True
        core.aboutToTerminate.connect(
            self._quitingApplication)  # Disconnected.

        core.workspace().currentDocumentChanged.connect(
            self._onDocumentChanged)  # Disconnected.
        core.workspace().textChanged.connect(
            self._onTextChanged)  # Disconnected.

        # If the user presses the accept button in the setting dialog, Enki
        # will force a rebuild of the whole project.
        #
        # TODO: only build if preview settings have been changed.
        #
        # In order to make this happen, let ``_onSettingsDialogAboutToExecute`` emit
        # a signal indicating that the CodeChat setting dialog has been opened. Save
        # core.config()['Sphinx'] and core.config()['CodeChat']. After dialogAccepted
        # is detected, compare current settings with the old one. Build if necessary.
        core.uiSettingsManager().dialogAccepted.connect(
            self._scheduleDocumentProcessing)  # Disconnected.

        core.workspace().modificationChanged.connect(
            self._onDocumentModificationChanged)  # disconnected

        self._scrollPos = {}
        self._vAtEnd = {}
        self._hAtEnd = {}

        # Keep track of which Sphinx template copies we've already asked the user about.
        self._sphinxTemplateCheckIgnoreList = []

        self._sphinxConverter = SphinxConverter(self)  # stopped
        self._runLatest = RunLatest('QThread', parent=self)

        self._visiblePath = None

        # If we update Preview on every key press, freezes are noticable (the
        # GUI thread draws the preview too slowly).
        # This timer is used for drawing Preview 800 ms After user has stopped typing text
        self._typingTimer = QTimer()  # stopped.
        self._typingTimer.setInterval(800)
        self._typingTimer.timeout.connect(
            self._scheduleDocumentProcessing)  # Disconnected.

        self.previewSync = PreviewSync(self)  # del_ called

        self._applyJavaScriptEnabled(self._isJavaScriptEnabled())

        # Clear flags used to temporarily disable signals during
        # ``_scheduleDocumentProcessing.``.
        self._ignoreDocumentChanged = False
        self._ignoreTextChanges = False

        # Provide an inital value for the rebuild needed flag.
        self._rebuildNeeded = False

        # Save the initial font, then restore it after a ``clear``. Note that
        # ``clear()`` doesn't reset the `currentCharFormat
        # <http://doc.qt.io/qt-4.8/qplaintextedit.html#currentCharFormat>`_. In
        # fact, clicking in red (error/warning) message in the log window
        # changes the current font to red! So, save it here so that it will be
        # restored correctly on a ``_clear_log``.
        self._defaultLogFont = self._widget.teLog.currentCharFormat()
        # The logWindowClear signal clears the log window.
        self._sphinxConverter.logWindowClear.connect(
            self._clear_log)  # disconnected
        # The logWindowText signal simply appends text to the log window.
        self._sphinxConverter.logWindowText.connect(
            lambda s: self._widget.teLog.appendPlainText(s))  # disconnected

    def _createWidget(self):
        widget = QWidget(self)
        uic.loadUi(os.path.join(os.path.dirname(__file__), 'Preview.ui'),
                   widget)
        widget.layout().setContentsMargins(0, 0, 0, 0)
        widget.webView.page().setLinkDelegationPolicy(
            QWebPage.DelegateAllLinks)
        widget.webView.page().linkClicked.connect(
            self._onLinkClicked)  # Disconnected.
        # Fix preview palette. See https://github.com/bjones1/enki/issues/34
        webViewPalette = widget.webView.palette()
        webViewPalette.setColor(QPalette.Inactive, QPalette.HighlightedText,
                                webViewPalette.color(QPalette.Text))
        widget.webView.setPalette(webViewPalette)

        widget.webView.page().mainFrame().titleChanged.connect(
            self._updateTitle)  # Disconnected.
        widget.cbEnableJavascript.clicked.connect(
            self._onJavaScriptEnabledCheckbox)  # Disconnected.
        widget.webView.installEventFilter(self)

        self.setWidget(widget)
        self.setFocusProxy(widget.webView)

        widget.tbSave.clicked.connect(self.onPreviewSave)  # Disconnected.
        # Add an attribute to ``widget`` denoting the splitter location.
        # This value will be overwritten when the user changes splitter location.
        widget.splitterErrorStateSize = (199, 50)
        widget.splitterNormStateSize = (1, 0)
        widget.splitterNormState = True
        widget.splitter.setSizes(widget.splitterNormStateSize)
        widget.splitter.splitterMoved.connect(
            self.on_splitterMoved)  # Disconnected.

        return widget

    def _quitingApplication(self):
        self._programRunning = False

    def on_splitterMoved(self, pos, index):
        if self._widget.splitterNormState:
            self._widget.splitterNormStateSize = self._widget.splitter.sizes()
        else:
            self._widget.splitterErrorStateSize = self._widget.splitter.sizes()

    def terminate(self):
        """Uninstall themselves
        """
        self._typingTimer.stop()
        self._typingTimer.timeout.disconnect(self._scheduleDocumentProcessing)
        try:
            self._widget.webView.page().mainFrame().loadFinished.disconnect(
                self._restoreScrollPos)
        except TypeError:  # already has been disconnected
            pass
        self.previewSync.terminate()
        core.workspace().modificationChanged.disconnect(
            self._onDocumentModificationChanged)

        self._widget.cbTemplate.currentIndexChanged.disconnect(
            self._onCurrentTemplateChanged)
        core.aboutToTerminate.disconnect(self._quitingApplication)
        core.workspace().currentDocumentChanged.disconnect(
            self._onDocumentChanged)
        core.workspace().textChanged.disconnect(self._onTextChanged)
        core.uiSettingsManager().dialogAccepted.disconnect(
            self._scheduleDocumentProcessing)
        self._widget.webView.page().linkClicked.disconnect(self._onLinkClicked)
        self._widget.webView.page().mainFrame().titleChanged.disconnect(
            self._updateTitle)
        self._widget.cbEnableJavascript.clicked.disconnect(
            self._onJavaScriptEnabledCheckbox)
        self._widget.tbSave.clicked.disconnect(self.onPreviewSave)
        self._widget.splitter.splitterMoved.disconnect(self.on_splitterMoved)
        self._sphinxConverter.logWindowClear.disconnect(self._clear_log)
        self._sphinxConverter.logWindowText.disconnect()

        self._sphinxConverter.terminate()
        self._runLatest.terminate()

    def closeEvent(self, event):
        """Widget is closed. Clear it
        """
        self.closed.emit()
        self._clear()
        return DockWidget.closeEvent(self, event)

    def _clear_log(self):
        """Clear the log window and reset the default font."""
        self._widget.teLog.clear()
        self._widget.teLog.setCurrentCharFormat(self._defaultLogFont)

    def eventFilter(self, obj, ev):
        """Event filter for the web view
        Zooms the web view
        """
        if isinstance(ev, QWheelEvent) and \
           ev.modifiers() == Qt.ControlModifier:
            multiplier = 1 + (0.1 * (ev.angleDelta().y() / 120.))
            view = self._widget.webView
            view.setZoomFactor(view.zoomFactor() * multiplier)
            return True
        else:
            return DockWidget.eventFilter(self, obj, ev)

    def _onDocumentModificationChanged(self, document, modified):
        if not modified:  # probably has been saved just now
            if not self._ignoreDocumentChanged:
                self._scheduleDocumentProcessing()

    def _onLinkClicked(self, url):
        res = QDesktopServices.openUrl(url)
        if res:
            core.mainWindow().statusBar().showMessage(
                "{} opened in a browser".format(url.toString()), 2000)
        else:
            core.mainWindow().statusBar().showMessage(
                "Failed to open {}".format(url.toString()), 2000)

    def _updateTitle(self, pageTitle):
        """Web page title changed. Update own title.
        """
        if pageTitle:
            self.setWindowTitle("Previe&w - " + pageTitle)
        else:
            self.setWindowTitle("Previe&w")

    def _saveScrollPos(self):
        """Save scroll bar position for document
        """
        frame = self._widget.webView.page().mainFrame()
        if frame.contentsSize() == QSize(0, 0):
            return  # no valida data, nothing to save

        pos = frame.scrollPosition()
        self._scrollPos[self._visiblePath] = pos
        self._hAtEnd[self._visiblePath] = frame.scrollBarMaximum(
            Qt.Horizontal) == pos.x()
        self._vAtEnd[self._visiblePath] = frame.scrollBarMaximum(
            Qt.Vertical) == pos.y()

    def _restoreScrollPos(self, ok):
        """Restore scroll bar position for document
        """
        try:
            self._widget.webView.page().mainFrame().loadFinished.disconnect(
                self._restoreScrollPos)
        except TypeError:  # already has been disconnected
            pass

        if core.workspace().currentDocument() is None:
            return  # nothing to restore if don't have document

        if not self._visiblePath in self._scrollPos:
            return  # no data for this document

        # Don't restore the scroll position if the window is hidden. This can
        # happen when the current document is changed, which invokes _clear,
        # which calls setHtml, which calls _saveScrollPos and then this routine
        # when the HTML is loaded.
        if not self.isVisible():
            return

        frame = self._widget.webView.page().mainFrame()

        frame.setScrollPosition(self._scrollPos[self._visiblePath])

        if self._hAtEnd[self._visiblePath]:
            frame.setScrollBarValue(Qt.Horizontal,
                                    frame.scrollBarMaximum(Qt.Horizontal))

        if self._vAtEnd[self._visiblePath]:
            frame.setScrollBarValue(Qt.Vertical,
                                    frame.scrollBarMaximum(Qt.Vertical))

        # Re-sync the re-loaded text.
        self.previewSync.syncTextToPreview()

    def _onDocumentChanged(self, old, new):
        """Current document changed, update preview
        """
        self._typingTimer.stop()
        if new is not None:
            if new.qutepart.language() == 'Markdown':
                self._widget.cbTemplate.show()
                self._widget.lTemplate.show()
            else:
                self._widget.cbTemplate.hide()
                self._widget.lTemplate.hide()

            self._clear()

            if self.isVisible():
                self._scheduleDocumentProcessing()

    _CUSTOM_TEMPLATE_PATH = '<custom template>'

    def _loadTemplates(self):
        for path in [
                os.path.join(os.path.dirname(__file__), 'templates'),
                os.path.expanduser('~/.enki/markdown-templates')
        ]:
            if os.path.isdir(path):
                for fileName in os.listdir(path):
                    fullPath = os.path.join(path, fileName)
                    if os.path.isfile(fullPath):
                        self._widget.cbTemplate.addItem(fileName, fullPath)

        self._widget.cbTemplate.addItem('Custom...',
                                        self._CUSTOM_TEMPLATE_PATH)

        self._restorePreviousTemplate()

    def _restorePreviousTemplate(self):
        # restore previous template
        index = self._widget.cbTemplate.findText(
            core.config()['Preview']['Template'])
        if index != -1:
            self._widget.cbTemplate.setCurrentIndex(index)

    def _getCurrentTemplatePath(self):
        index = self._widget.cbTemplate.currentIndex()
        if index == -1:  # empty combo
            return ''

        return str(self._widget.cbTemplate.itemData(index))

    def _getCurrentTemplate(self):
        path = self._getCurrentTemplatePath()
        if not path:
            return ''

        try:
            with open(path) as file:
                text = file.read()
        except Exception as ex:
            text = 'Failed to load template {}: {}'.format(path, ex)
            core.mainWindow().statusBar().showMessage(text)
            return ''
        else:
            return text

    def _onCurrentTemplateChanged(self):
        """Update text or show message to the user"""
        if self._getCurrentTemplatePath() == self._CUSTOM_TEMPLATE_PATH:
            QMessageBox.information(
                core.mainWindow(), 'Custom templaes help',
                '<html>See <a href="https://github.com/hlamer/enki/wiki/Markdown-preview-templates">'
                'this</a> wiki page for information about custom templates')
            self._restorePreviousTemplate()

        core.config(
        )['Preview']['Template'] = self._widget.cbTemplate.currentText()
        core.config().flush()
        self._scheduleDocumentProcessing()

    def _onTextChanged(self, document):
        """Text changed, update preview
        """
        if self.isVisible() and not self._ignoreTextChanges:
            self._typingTimer.stop()
            self._typingTimer.start()

    def show(self):
        """When shown, update document, if possible.
        """
        DockWidget.show(self)
        self._scheduleDocumentProcessing()

    def _clear(self):
        """Clear the preview dock contents.
        Might be necesssary for stop executing JS and loading data.
        """
        self._setHtml('', '', None, QUrl())

    def _isJavaScriptEnabled(self):
        """Check if JS is enabled in the settings.
        """
        return core.config()['Preview']['JavaScriptEnabled']

    def _onJavaScriptEnabledCheckbox(self, enabled):
        """Checkbox clicked, save and apply settings
        """
        core.config()['Preview']['JavaScriptEnabled'] = enabled
        core.config().flush()

        self._applyJavaScriptEnabled(enabled)

    def _applyJavaScriptEnabled(self, enabled):
        """Update QWebView settings and QCheckBox state
        """
        self._widget.cbEnableJavascript.setChecked(enabled)

        settings = self._widget.webView.settings()
        settings.setAttribute(settings.JavascriptEnabled, enabled)

    def onPreviewSave(self):
        """Save contents of the preview pane to a user-specified file."""
        path, _ = QFileDialog.getSaveFileName(self,
                                              'Save Preview as HTML',
                                              filter='HTML (*.html)')
        if path:
            self._previewSave(path)

    def _previewSave(self, path):
        """Save contents of the preview pane to the file given by path."""
        text = self._widget.webView.page().mainFrame().toHtml()
        try:
            with open(path, 'w', encoding='utf-8') as openedFile:
                openedFile.write(text)
        except (OSError, IOError) as ex:
            QMessageBox.critical(self, "Failed to save HTML", str(ex))

    # HTML generation
    #----------------
    # The following methods all support generation of HTML from text in the
    # Qutepart window in a separate thread.
    def _scheduleDocumentProcessing(self):
        """Start document processing with the thread.
        """
        if not self._programRunning:
            return

        if self.isHidden():
            return

        self._typingTimer.stop()

        document = core.workspace().currentDocument()
        if document is not None:
            if sphinxEnabledForFile(document.filePath()):
                self._copySphinxProjectTemplate(document.filePath())
            qp = document.qutepart
            language = qp.language()
            text = qp.text
            sphinxCanProcess = sphinxEnabledForFile(document.filePath())
            # Determine if we're in the middle of a build.
            currentlyBuilding = self._widget.prgStatus.text() == 'Building...'

            if language == 'Markdown':
                text = self._getCurrentTemplate() + text
                # Hide the progress bar, since processing is usually short and
                # Markdown produces no errors or warnings to display in the
                # progress bar. See https://github.com/bjones1/enki/issues/36.
                self._widget.prgStatus.setVisible(False)
                # Hide the error log, since Markdown never generates errors or
                # warnings.
                self._widget.teLog.setVisible(False)
            elif isHtmlFile(document):
                # No processing needed -- just display it.
                self._setHtml(document.filePath(), text, None, QUrl())
                # Hide the progress bar, since no processing is necessary.
                self._widget.prgStatus.setVisible(False)
                # Hide the error log, since we do not HTML checking.
                self._widget.teLog.setVisible(False)
                return
            elif ((language == 'reStructuredText') or sphinxCanProcess
                  or canUseCodeChat(document.filePath())):
                # Show the progress bar and error log for reST, CodeChat, or
                # Sphinx builds. It will display progress (Sphinx only) and
                # errors/warnings (for all three).
                self._widget.prgStatus.setVisible(True)
                self._widget.teLog.setVisible(True)
                self._setHtmlProgress('Building...')

            # Determine whether to initiate a build or not. The underlying
            # logic:
            #
            # - If Sphinx can't process this file, just build it.
            # - If Sphinx can process this file:
            #
            #   - If the document isn't internally modified, we're here because
            #     the file was saved or the refresh button was pressed. Build it.
            #   - If the document was internally modified and "insta-build" is
            #     enabled (i.e. build only on save is disabled):
            #
            #     - If the document was not externally modified, then save and
            #       build.
            #     - If the document was externally modified, DANGER! The user
            #       needs to decide which file wins (external changes or
            #       internal changes). Don't save and build, since this would
            #       overwrite external modifications without the user realizing
            #       what happened. Instead, warn the user.
            #
            # As a table, see below. Build, Save, and Warn are the outputs; all
            # others are inputs.
            #
            # ==================  ===================  ===================  =============  =====  ====  ====
            # Sphinx can process  Internally modified  Externally modified  Build on Save  Build  Save  Warn
            # ==================  ===================  ===================  =============  =====  ====  ====
            # No                  X                    X                    X              Yes    No    No
            # Yes                 No                   X                    X              Yes    No    No
            # Yes                 Yes                  No                   No             Yes    Yes   No
            # Yes                 Yes                  Yes                  No             No     No    Yes
            # Yes                 Yes                  X                    Yes            No     No    No
            # ==================  ===================  ===================  =============  =====  ====  ====
            internallyModified = qp.document().isModified()
            externallyModified = document.isExternallyModified()
            buildOnSave = core.config()['Sphinx']['BuildOnSave']
            saveThenBuild = (sphinxCanProcess and internallyModified
                             and not externallyModified and not buildOnSave)
            # If Sphinx is currently building, don't autosave -- this can
            # cause Sphinx to miss changes on its next build. Instead, wait
            # until Sphinx completes, then do a save and build.
            if saveThenBuild and currentlyBuilding:
                self._rebuildNeeded = True
                saveThenBuild = False
            else:
                self._rebuildNeeded = False
            # Save first, if needed.
            if saveThenBuild:
                # If trailing whitespace strip changes the cursor position,
                # restore the whitespace and cursor position.
                lineNum, col = qp.cursorPosition
                lineText = qp.lines[lineNum]
                # Invoking saveFile when Strip Trailing whitespace is enabled
                # causes ``onTextChanged`` (due to whitespace strips) and
                # ``onDocumentChanged`` signals to be emitted. These both
                # re-invoke this routine, causing a double build. So, ignore
                # both these signals.
                self._ignoreDocumentChanged = True
                self._ignoreTextChanges = True
                document.saveFile()
                self._ignoreDocumentChanged = False
                self._ignoreTextChanges = False
                if qp.cursorPosition != (lineNum, col):
                    # Mark this as one operation on the undo stack. To do so,
                    # enclose all editing operations in a context manager. See
                    # "Text modification and Undo/Redo" in the qutepart docs.
                    with qp:
                        qp.lines[lineNum] = lineText
                        qp.cursorPosition = lineNum, col
                    qp.document().setModified(False)
            # Build. Each line is one row in the table above.
            if ((not sphinxCanProcess)
                    or (sphinxCanProcess and not internallyModified)
                    or saveThenBuild):
                # Build the HTML in a separate thread.
                self._runLatest.start(self._setHtmlFuture, self.getHtml,
                                      language, text, document.filePath())
            # Warn.
            if (sphinxCanProcess and internallyModified and externallyModified
                    and not buildOnSave):
                core.mainWindow().appendMessage(
                    'Warning: file modified externally. Auto-save disabled.')

    def getHtml(self, language, text, filePath):
        """Get HTML for document. This is run in a separate thread.
        """
        if language == 'Markdown':
            return filePath, _convertMarkdown(text), None, QUrl()
        # For ReST, use docutils only if Sphinx isn't available.
        elif language == 'reStructuredText' and not sphinxEnabledForFile(
                filePath):
            htmlUnicode, errString = _convertReST(text)
            return filePath, htmlUnicode, errString, QUrl()
        elif filePath and sphinxEnabledForFile(
                filePath):  # Use Sphinx to generate the HTML if possible.
            return self._sphinxConverter.convert(filePath)
        elif filePath and canUseCodeChat(
                filePath):  # Otherwise, fall back to using CodeChat+docutils.
            return _convertCodeChat(text, filePath)
        else:
            return filePath, 'No preview for this type of file', None, QUrl()

    def _copySphinxProjectTemplate(self, documentFilePath):
        """Add conf.py, CodeChat.css and index.rst (if ther're missing)
        to the Sphinx project directory.
        """
        if core.config(
        )['Sphinx']['ProjectPath'] in self._sphinxTemplateCheckIgnoreList:
            return

        # Check for the existance Sphinx project files. Copy skeleton versions
        # of them to the project if necessary.
        sphinxPluginsPath = os.path.dirname(os.path.realpath(__file__))
        sphinxTemplatePath = os.path.join(sphinxPluginsPath,
                                          'sphinx_templates')
        sphinxProjectPath = core.config()['Sphinx']['ProjectPath']
        errors = []
        checklist = ['index.rst', 'conf.py']
        if core.config()['CodeChat']['Enabled'] and CodeChat:
            checklist.append('CodeChat.css')
        missinglist = []
        for filename in checklist:
            if not os.path.exists(os.path.join(sphinxProjectPath, filename)):
                missinglist.append(filename)
        if not missinglist:
            return errors

        # For testing, check for test-provided button presses
        if ((len(self._sphinxTemplateCheckIgnoreList) == 1)
                and isinstance(self._sphinxTemplateCheckIgnoreList[0], int)):
            res = self._sphinxTemplateCheckIgnoreList[0]
        else:
            res = QMessageBox.warning(
                self, r"Enki", "Sphinx project at:\n " + sphinxProjectPath +
                "\nis missing the template file(s): " + ' '.join(missinglist) +
                ". Auto-generate those file(s)?",
                QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
                QMessageBox.Yes)
        if res != QMessageBox.Yes:
            if res == QMessageBox.No:
                self._sphinxTemplateCheckIgnoreList.append(sphinxProjectPath)
            return

        if core.config()['CodeChat']['Enabled'] and CodeChat:
            codeChatPluginsPath = os.path.dirname(
                os.path.realpath(CodeChat.__file__))
            codeChatTemplatePath = os.path.join(codeChatPluginsPath,
                                                'template')
            copyTemplateFile(errors, codeChatTemplatePath, 'index.rst',
                             sphinxProjectPath)
            copyTemplateFile(errors, codeChatTemplatePath, 'conf.py',
                             sphinxProjectPath)
            copyTemplateFile(errors, codeChatTemplatePath, 'CodeChat.css',
                             sphinxProjectPath)
        else:
            copyTemplateFile(errors, sphinxTemplatePath, 'index.rst',
                             sphinxProjectPath)
            copyTemplateFile(errors, sphinxTemplatePath, 'conf.py',
                             sphinxProjectPath)

        errInfo = ""
        for error in errors:
            errInfo += "Copy from " + error[0] + " to " + error[
                1] + " caused error " + error[2] + ';\n'
        if errInfo:
            QMessageBox.warning(
                self, "Sphinx template file copy error",
                "Copy template project files failed. The following errors are returned:<br>"
                + errInfo)

        return errors

    def _setHtmlFuture(self, future):
        """Receives a future and unpacks the result, calling _setHtml."""
        filePath, htmlText, errString, baseUrl = future.result
        self._setHtml(filePath, htmlText, errString, baseUrl)

    def _setHtml(self, filePath, htmlText, errString, baseUrl):
        """Set HTML to the view and restore scroll bars position.
        Called by the thread
        """

        self._saveScrollPos()
        self._visiblePath = filePath
        self._widget.webView.page().mainFrame().loadFinished.connect(
            self._restoreScrollPos)  # disconnected

        if baseUrl.isEmpty():
            # Clear the log, then update it with build content.
            self._widget.teLog.clear()
            self._widget.webView.setHtml(htmlText,
                                         baseUrl=QUrl.fromLocalFile(filePath))
        else:
            self._widget.webView.setUrl(baseUrl)

        # If there were messages from the conversion process, extract a count of
        # errors and warnings from these messages.
        if errString:
            # If there are errors/warnings, expand log window to make it visible
            if self._widget.splitterNormState:
                self._widget.splitterNormStateSize = self._widget.splitter.sizes(
                )
                self._widget.splitterNormState = False
            self._widget.splitter.setSizes(self._widget.splitterErrorStateSize)

            # This code parses the error string to determine get the number of
            # warnings and errors. Common docutils error messages read::
            #
            #  <string>:1589: (ERROR/3) Unknown interpreted text role "ref".
            #
            #  X:\ode.py:docstring of sympy:5: (ERROR/3) Unexpected indentation.
            #
            # and common sphinx errors read::
            #
            #  X:\SVM_train.m.rst:2: SEVERE: Title overline & underline mismatch.
            #
            #  X:\indexs.rst:None: WARNING: image file not readable: a.jpg
            #
            #  X:\conf.py.rst:: WARNING: document isn't included in any toctree
            #
            # Each error/warning occupies one line. The following `regular
            # expression
            # <https://docs.python.org/2/library/re.html#regular-expression-syntax>`_
            # is designed to find the error position (1589/None) and message
            # type (ERROR/WARNING/SEVERE). Extra spaces are added to show which
            # parts of the example string it matches. For more details about
            # Python regular expressions, refer to the
            # `re docs <https://docs.python.org/2/library/re.html>`_.
            #
            # Examining this expression one element at a time::
            #
            #   <string>:1589:        (ERROR/3)Unknown interpreted text role "ref".
            errPosRe = ':(\d*|None): '
            # Find the first occurence of a pair of colons.
            # Between them there can be numbers or "None" or nothing. For example,
            # this expression matches the string ":1589:" or string ":None:" or
            # string "::". Next::
            #
            #   <string>:1589:        (ERROR/3)Unknown interpreted text role "ref".
            errTypeRe = '\(?(WARNING|ERROR|SEVERE)'
            # Next match the error type, which can
            # only be "WARNING", "ERROR" or "SEVERE". Before this error type the
            # message may optionally contain one left parenthesis.
            #
            errEolRe = '.*$'
            # Since one error message occupies one line, a ``*``
            # quantifier is used along with end-of-line ``$`` to make sure only
            # the first match is used in each line.
            #
            # TODO: Is this necesary? Is there any case where omitting this
            # causes a failure?
            regex = re.compile(
                errPosRe + errTypeRe + errEolRe,
                # The message usually contain multiple lines; search each line
                # for errors and warnings.
                re.MULTILINE)
            # Use findall to return all matches in the message, not just the
            # first.
            result = regex.findall(errString)

            # The variable ``result`` now contains a list of tuples, where each
            # tuples contains the two matched groups (line number, error_string).
            # For example::
            #
            #  [('1589', 'ERROR')]
            #
            # Therefeore, the second element of each tuple, represented as x[1],
            # is the error_string. The next two lines of code will collect all
            # ERRORs/SEVEREs and WARNINGs found in the error_string separately.
            errNum = sum([x[1] == 'ERROR' or x[1] == 'SEVERE' for x in result])
            warningNum = [x[1] for x in result].count('WARNING')
            # Report these results this to the user.
            status = 'Error(s): {}, warning(s): {}'.format(errNum, warningNum)
            # Since the error string might contain characters such as ">" and "<",
            # they need to be converted to "&gt;" and "&lt;" such that
            # they can be displayed correctly in the log window as HTML strings.
            # This step is handled by ``html.escape``.
            self._widget.teLog.appendHtml("<pre><font color='red'>\n" +
                                          html.escape(errString) +
                                          '</font></pre>')
            # Update the progress bar.
            color = 'red' if errNum else '#FF9955' if warningNum else None
            self._setHtmlProgress(status, color)
        else:
            # If there are no errors/warnings, collapse the log window (can mannually
            # expand it back to visible)
            if not self._widget.splitterNormState:
                self._widget.splitterErrorStateSize = self._widget.splitter.sizes(
                )
                self._widget.splitterNormState = True
            self._widget.splitter.setSizes(self._widget.splitterNormStateSize)
            self._setHtmlProgress('Error(s): 0, warning(s): 0')

        # Do a rebuild if needed.
        if self._rebuildNeeded:
            self._rebuildNeeded = False
            self._scheduleDocumentProcessing()

    def _setHtmlProgress(self, text, color=None):
        """Set progress label.
        """
        if color:
            style = 'QLabel { background-color: ' + color + '; }'
        else:
            style = style = 'QLabel {}'
        self._widget.prgStatus.setStyleSheet(style)
        self._widget.prgStatus.setText(text)
Ejemplo n.º 6
0
class PreviewDock(DockWidget):
    """GUI and implementation
    """
    # Emitted when this window is closed.
    closed = pyqtSignal()

    def __init__(self):
        DockWidget.__init__(self, core.mainWindow(), "Previe&w", QIcon(':/enkiicons/internet.png'), "Alt+W")

        self._widget = self._createWidget()
        # Don't need to schedule document processing; a call to show() does.

        self._loadTemplates()
        self._widget.cbTemplate.currentIndexChanged.connect(
            self._onCurrentTemplateChanged)  # Disconnected.

        # When quitting this program, don't rebuild when closing all open
        # documents. This can take a long time, particularly if a some of the
        # documents are associated with a Sphinx project.
        self._programRunning = True
        core.aboutToTerminate.connect(self._quitingApplication)  # Disconnected.

        core.workspace().currentDocumentChanged.connect(self._onDocumentChanged)  # Disconnected.
        core.workspace().textChanged.connect(self._onTextChanged)  # Disconnected.

        # If the user presses the accept button in the setting dialog, Enki
        # will force a rebuild of the whole project.
        #
        # TODO: only build if preview settings have been changed.
        #
        # In order to make this happen, let ``_onSettingsDialogAboutToExecute`` emit
        # a signal indicating that the CodeChat setting dialog has been opened. Save
        # core.config()['Sphinx'] and core.config()['CodeChat']. After dialogAccepted
        # is detected, compare current settings with the old one. Build if necessary.
        core.uiSettingsManager().dialogAccepted.connect(
            self._scheduleDocumentProcessing)  # Disconnected.

        core.workspace().modificationChanged.connect(
            self._onDocumentModificationChanged)  # disconnected

        self._scrollPos = {}
        self._vAtEnd = {}
        self._hAtEnd = {}

        # Keep track of which Sphinx template copies we've already asked the user about.
        self._sphinxTemplateCheckIgnoreList = []

        self._sphinxConverter = SphinxConverter(self)  # stopped
        self._runLatest = RunLatest('QThread', parent=self)

        self._visiblePath = None

        # If we update Preview on every key press, freezes are noticable (the
        # GUI thread draws the preview too slowly).
        # This timer is used for drawing Preview 800 ms After user has stopped typing text
        self._typingTimer = QTimer()  # stopped.
        self._typingTimer.setInterval(800)
        self._typingTimer.timeout.connect(self._scheduleDocumentProcessing)  # Disconnected.

        self.previewSync = PreviewSync(self)  # del_ called

        self._applyJavaScriptEnabled(self._isJavaScriptEnabled())

        # Clear flags used to temporarily disable signals during
        # ``_scheduleDocumentProcessing.``.
        self._ignoreDocumentChanged = False
        self._ignoreTextChanges = False

        # Provide an inital value for the rebuild needed flag.
        self._rebuildNeeded = False

        # Save the initial font, then restore it after a ``clear``. Note that
        # ``clear()`` doesn't reset the `currentCharFormat
        # <http://doc.qt.io/qt-4.8/qplaintextedit.html#currentCharFormat>`_. In
        # fact, clicking in red (error/warning) message in the log window
        # changes the current font to red! So, save it here so that it will be
        # restored correctly on a ``_clear_log``.
        self._defaultLogFont = self._widget.teLog.currentCharFormat()
        # The logWindowClear signal clears the log window.
        self._sphinxConverter.logWindowClear.connect(self._clear_log)  # disconnected
        # The logWindowText signal simply appends text to the log window.
        self._sphinxConverter.logWindowText.connect(lambda s:
                                           self._widget.teLog.appendPlainText(s))  # disconnected

    def _createWidget(self):
        widget = QWidget(self)
        uic.loadUi(os.path.join(os.path.dirname(__file__), 'Preview.ui'), widget)
        widget.layout().setContentsMargins(0, 0, 0, 0)
        widget.webView.page().setLinkDelegationPolicy(QWebPage.DelegateAllLinks)
        widget.webView.page().linkClicked.connect(self._onLinkClicked)  # Disconnected.
        # Fix preview palette. See https://github.com/bjones1/enki/issues/34
        webViewPalette = widget.webView.palette()
        webViewPalette.setColor(QPalette.Inactive, QPalette.HighlightedText,
                                webViewPalette.color(QPalette.Text))
        widget.webView.setPalette(webViewPalette)

        widget.webView.page().mainFrame().titleChanged.connect(
            self._updateTitle)  # Disconnected.
        widget.cbEnableJavascript.clicked.connect(
            self._onJavaScriptEnabledCheckbox)  # Disconnected.
        widget.webView.installEventFilter(self)

        self.setWidget(widget)
        self.setFocusProxy(widget.webView)

        widget.tbSave.clicked.connect(self.onPreviewSave)  # Disconnected.
        # Add an attribute to ``widget`` denoting the splitter location.
        # This value will be overwritten when the user changes splitter location.
        widget.splitterErrorStateSize = (199, 50)
        widget.splitterNormStateSize = (1, 0)
        widget.splitterNormState = True
        widget.splitter.setSizes(widget.splitterNormStateSize)
        widget.splitter.splitterMoved.connect(self.on_splitterMoved)  # Disconnected.

        return widget

    def _quitingApplication(self):
        self._programRunning = False

    def on_splitterMoved(self, pos, index):
        if self._widget.splitterNormState:
            self._widget.splitterNormStateSize = self._widget.splitter.sizes()
        else:
            self._widget.splitterErrorStateSize = self._widget.splitter.sizes()

    def terminate(self):
        """Uninstall themselves
        """
        self._typingTimer.stop()
        self._typingTimer.timeout.disconnect(self._scheduleDocumentProcessing)
        try:
            self._widget.webView.page().mainFrame().loadFinished.disconnect(
                self._restoreScrollPos)
        except TypeError:  # already has been disconnected
            pass
        self.previewSync.terminate()
        core.workspace().modificationChanged.disconnect(
            self._onDocumentModificationChanged)

        self._widget.cbTemplate.currentIndexChanged.disconnect(
            self._onCurrentTemplateChanged)
        core.aboutToTerminate.disconnect(self._quitingApplication)
        core.workspace().currentDocumentChanged.disconnect(
            self._onDocumentChanged)
        core.workspace().textChanged.disconnect(self._onTextChanged)
        core.uiSettingsManager().dialogAccepted.disconnect(
            self._scheduleDocumentProcessing)
        self._widget.webView.page().linkClicked.disconnect(self._onLinkClicked)
        self._widget.webView.page().mainFrame().titleChanged.disconnect(
            self._updateTitle)
        self._widget.cbEnableJavascript.clicked.disconnect(
            self._onJavaScriptEnabledCheckbox)
        self._widget.tbSave.clicked.disconnect(self.onPreviewSave)
        self._widget.splitter.splitterMoved.disconnect(self.on_splitterMoved)
        self._sphinxConverter.logWindowClear.disconnect(self._clear_log)
        self._sphinxConverter.logWindowText.disconnect()

        self._sphinxConverter.terminate()
        self._runLatest.terminate()

    def closeEvent(self, event):
        """Widget is closed. Clear it
        """
        self.closed.emit()
        self._clear()
        return DockWidget.closeEvent(self, event)

    def _clear_log(self):
        """Clear the log window and reset the default font."""
        self._widget.teLog.clear()
        self._widget.teLog.setCurrentCharFormat(self._defaultLogFont)

    def eventFilter(self, obj, ev):
        """ Event filter for the web view
        Zooms the web view
        """
        if isinstance(ev, QWheelEvent) and \
           ev.modifiers() == Qt.ControlModifier:
            multiplier = 1 + (0.1 * (ev.angleDelta().y() / 120.))
            view = self._widget.webView
            view.setZoomFactor(view.zoomFactor() * multiplier)
            return True
        else:
            return DockWidget.eventFilter(self, obj, ev)

    def _onDocumentModificationChanged(self, document, modified):
        if not modified:  # probably has been saved just now
            if not self._ignoreDocumentChanged:
                self._scheduleDocumentProcessing()

    def _onLinkClicked(self, url):
        res = QDesktopServices.openUrl(url)
        if res:
            core.mainWindow().statusBar().showMessage("{} opened in a browser".format(url.toString()), 2000)
        else:
            core.mainWindow().statusBar().showMessage("Failed to open {}".format(url.toString()), 2000)

    def _updateTitle(self, pageTitle):
        """Web page title changed. Update own title.
        """
        if pageTitle:
            self.setWindowTitle("Previe&w - " + pageTitle)
        else:
            self.setWindowTitle("Previe&w")

    def _saveScrollPos(self):
        """Save scroll bar position for document
        """
        frame = self._widget.webView .page().mainFrame()
        if frame.contentsSize() == QSize(0, 0):
            return  # no valida data, nothing to save

        pos = frame.scrollPosition()
        self._scrollPos[self._visiblePath] = pos
        self._hAtEnd[self._visiblePath] = frame.scrollBarMaximum(Qt.Horizontal) == pos.x()
        self._vAtEnd[self._visiblePath] = frame.scrollBarMaximum(Qt.Vertical) == pos.y()

    def _restoreScrollPos(self, ok):
        """Restore scroll bar position for document
        """
        try:
            self._widget.webView.page().mainFrame().loadFinished.disconnect(self._restoreScrollPos)
        except TypeError:  # already has been disconnected
            pass

        if core.workspace().currentDocument() is None:
            return  # nothing to restore if don't have document

        if not self._visiblePath in self._scrollPos:
            return  # no data for this document

        # Don't restore the scroll position if the window is hidden. This can
        # happen when the current document is changed, which invokes _clear,
        # which calls setHtml, which calls _saveScrollPos and then this routine
        # when the HTML is loaded.
        if not self.isVisible():
            return

        frame = self._widget.webView.page().mainFrame()

        frame.setScrollPosition(self._scrollPos[self._visiblePath])

        if self._hAtEnd[self._visiblePath]:
            frame.setScrollBarValue(Qt.Horizontal, frame.scrollBarMaximum(Qt.Horizontal))

        if self._vAtEnd[self._visiblePath]:
            frame.setScrollBarValue(Qt.Vertical, frame.scrollBarMaximum(Qt.Vertical))

        # Re-sync the re-loaded text.
        self.previewSync.syncTextToPreview()

    def _onDocumentChanged(self, old, new):
        """Current document changed, update preview
        """
        self._typingTimer.stop()
        if new is not None:
            if new.qutepart.language() == 'Markdown':
                self._widget.cbTemplate.show()
                self._widget.lTemplate.show()
            else:
                self._widget.cbTemplate.hide()
                self._widget.lTemplate.hide()

            self._clear()

            if self.isVisible():
                self._scheduleDocumentProcessing()

    _CUSTOM_TEMPLATE_PATH = '<custom template>'

    def _loadTemplates(self):
        for path in [os.path.join(os.path.dirname(__file__), 'templates'),
                     os.path.expanduser('~/.enki/markdown-templates')]:
            if os.path.isdir(path):
                for fileName in os.listdir(path):
                    fullPath = os.path.join(path, fileName)
                    if os.path.isfile(fullPath):
                        self._widget.cbTemplate.addItem(fileName, fullPath)

        self._widget.cbTemplate.addItem('Custom...', self._CUSTOM_TEMPLATE_PATH)

        self._restorePreviousTemplate()

    def _restorePreviousTemplate(self):
        # restore previous template
        index = self._widget.cbTemplate.findText(core.config()['Preview']['Template'])
        if index != -1:
            self._widget.cbTemplate.setCurrentIndex(index)

    def _getCurrentTemplatePath(self):
        index = self._widget.cbTemplate.currentIndex()
        if index == -1:  # empty combo
            return ''

        return str(self._widget.cbTemplate.itemData(index))

    def _getCurrentTemplate(self):
        path = self._getCurrentTemplatePath()
        if not path:
            return ''

        try:
            with open(path) as file:
                text = file.read()
        except Exception as ex:
            text = 'Failed to load template {}: {}'.format(path, ex)
            core.mainWindow().statusBar().showMessage(text)
            return ''
        else:
            return text

    def _onCurrentTemplateChanged(self):
        """Update text or show message to the user"""
        if self._getCurrentTemplatePath() == self._CUSTOM_TEMPLATE_PATH:
            QMessageBox.information(
                core.mainWindow(),
                'Custom templaes help',
                '<html>See <a href="https://github.com/hlamer/enki/wiki/Markdown-preview-templates">'
                'this</a> wiki page for information about custom templates')
            self._restorePreviousTemplate()

        core.config()['Preview']['Template'] = self._widget.cbTemplate.currentText()
        core.config().flush()
        self._scheduleDocumentProcessing()

    def _onTextChanged(self, document):
        """Text changed, update preview
        """
        if self.isVisible() and not self._ignoreTextChanges:
            self._typingTimer.stop()
            self._typingTimer.start()

    def show(self):
        """When shown, update document, if possible.
        """
        DockWidget.show(self)
        self._scheduleDocumentProcessing()

    def _clear(self):
        """Clear the preview dock contents.
        Might be necesssary for stop executing JS and loading data.
        """
        self._setHtml('', '', None, QUrl())

    def _isJavaScriptEnabled(self):
        """Check if JS is enabled in the settings.
        """
        return core.config()['Preview']['JavaScriptEnabled']

    def _onJavaScriptEnabledCheckbox(self, enabled):
        """Checkbox clicked, save and apply settings
        """
        core.config()['Preview']['JavaScriptEnabled'] = enabled
        core.config().flush()

        self._applyJavaScriptEnabled(enabled)

    def _applyJavaScriptEnabled(self, enabled):
        """Update QWebView settings and QCheckBox state
        """
        self._widget.cbEnableJavascript.setChecked(enabled)

        settings = self._widget.webView.settings()
        settings.setAttribute(settings.JavascriptEnabled, enabled)

    def onPreviewSave(self):
        """Save contents of the preview pane to a user-specified file."""
        path, _ = QFileDialog.getSaveFileName(self, 'Save Preview as HTML', filter='HTML (*.html)')
        if path:
            self._previewSave(path)

    def _previewSave(self, path):
        """Save contents of the preview pane to the file given by path."""
        text = self._widget.webView.page().mainFrame().toHtml()
        try:
            with open(path, 'w', encoding='utf-8') as openedFile:
                openedFile.write(text)
        except (OSError, IOError) as ex:
            QMessageBox.critical(self, "Failed to save HTML", str(ex))

    # HTML generation
    #----------------
    # The following methods all support generation of HTML from text in the
    # Qutepart window in a separate thread.
    def _scheduleDocumentProcessing(self):
        """Start document processing with the thread.
        """
        if not self._programRunning:
            return

        if self.isHidden():
            return

        self._typingTimer.stop()

        document = core.workspace().currentDocument()
        if document is not None:
            if sphinxEnabledForFile(document.filePath()):
                self._copySphinxProjectTemplate(document.filePath())
            qp = document.qutepart
            language = qp.language()
            text = qp.text
            sphinxCanProcess = sphinxEnabledForFile(document.filePath())
            # Determine if we're in the middle of a build.
            currentlyBuilding = self._widget.prgStatus.text() == 'Building...'

            if language == 'Markdown':
                text = self._getCurrentTemplate() + text
                # Hide the progress bar, since processing is usually short and
                # Markdown produces no errors or warnings to display in the
                # progress bar. See https://github.com/bjones1/enki/issues/36.
                self._widget.prgStatus.setVisible(False)
                # Hide the error log, since Markdown never generates errors or
                # warnings.
                self._widget.teLog.setVisible(False)
            elif isHtmlFile(document):
                # No processing needed -- just display it.
                self._setHtml(document.filePath(), text, None, QUrl())
                # Hide the progress bar, since no processing is necessary.
                self._widget.prgStatus.setVisible(False)
                # Hide the error log, since we do not HTML checking.
                self._widget.teLog.setVisible(False)
                return
            elif ((language == 'reStructuredText') or sphinxCanProcess or
                  canUseCodeChat(document.filePath())):
                # Show the progress bar and error log for reST, CodeChat, or
                # Sphinx builds. It will display progress (Sphinx only) and
                # errors/warnings (for all three).
                self._widget.prgStatus.setVisible(True)
                self._widget.teLog.setVisible(True)
                self._setHtmlProgress('Building...')

            # Determine whether to initiate a build or not. The underlying
            # logic:
            #
            # - If Sphinx can't process this file, just build it.
            # - If Sphinx can process this file:
            #
            #   - If the document isn't internally modified, we're here because
            #     the file was saved or the refresh button was pressed. Build it.
            #   - If the document was internally modified and "insta-build" is
            #     enabled (i.e. build only on save is disabled):
            #
            #     - If the document was not externally modified, then save and
            #       build.
            #     - If the document was externally modified, DANGER! The user
            #       needs to decide which file wins (external changes or
            #       internal changes). Don't save and build, since this would
            #       overwrite external modifications without the user realizing
            #       what happened. Instead, warn the user.
            #
            # As a table, see below. Build, Save, and Warn are the outputs; all
            # others are inputs.
            #
            # ==================  ===================  ===================  =============  =====  ====  ====
            # Sphinx can process  Internally modified  Externally modified  Build on Save  Build  Save  Warn
            # ==================  ===================  ===================  =============  =====  ====  ====
            # No                  X                    X                    X              Yes    No    No
            # Yes                 No                   X                    X              Yes    No    No
            # Yes                 Yes                  No                   No             Yes    Yes   No
            # Yes                 Yes                  Yes                  No             No     No    Yes
            # Yes                 Yes                  X                    Yes            No     No    No
            # ==================  ===================  ===================  =============  =====  ====  ====
            internallyModified = qp.document().isModified()
            externallyModified = document.isExternallyModified()
            buildOnSave = core.config()['Sphinx']['BuildOnSave']
            saveThenBuild = (sphinxCanProcess and internallyModified and
                             not externallyModified and not buildOnSave)
            # If Sphinx is currently building, don't autosave -- this can
            # cause Sphinx to miss changes on its next build. Instead, wait
            # until Sphinx completes, then do a save and build.
            if saveThenBuild and currentlyBuilding:
                self._rebuildNeeded = True
                saveThenBuild = False
            else:
                self._rebuildNeeded = False
            # Save first, if needed.
            if saveThenBuild:
                # If trailing whitespace strip changes the cursor position,
                # restore the whitespace and cursor position.
                lineNum, col = qp.cursorPosition
                lineText = qp.lines[lineNum]
                # Invoking saveFile when Strip Trailing whitespace is enabled
                # causes ``onTextChanged`` (due to whitespace strips) and
                # ``onDocumentChanged`` signals to be emitted. These both
                # re-invoke this routine, causing a double build. So, ignore
                # both these signals.
                self._ignoreDocumentChanged = True
                self._ignoreTextChanges = True
                document.saveFile()
                self._ignoreDocumentChanged = False
                self._ignoreTextChanges = False
                if qp.cursorPosition != (lineNum, col):
                    # Mark this as one operation on the undo stack. To do so,
                    # enclose all editing operations in a context manager. See
                    # "Text modification and Undo/Redo" in the qutepart docs.
                    with qp:
                        qp.lines[lineNum] = lineText
                        qp.cursorPosition = lineNum, col
                    qp.document().setModified(False)
            # Build. Each line is one row in the table above.
            if ((not sphinxCanProcess) or
                    (sphinxCanProcess and not internallyModified) or
                    saveThenBuild):
                # Build the HTML in a separate thread.
                self._runLatest.start(self._setHtmlFuture, self.getHtml,
                                language, text, document.filePath())
            # Warn.
            if (sphinxCanProcess and internallyModified and
                    externallyModified and not buildOnSave):
                core.mainWindow().appendMessage('Warning: file modified externally. Auto-save disabled.')

    def getHtml(self, language, text, filePath):
        """Get HTML for document. This is run in a separate thread.
        """
        if language == 'Markdown':
            return filePath, _convertMarkdown(text), None, QUrl()
        # For ReST, use docutils only if Sphinx isn't available.
        elif language == 'reStructuredText' and not sphinxEnabledForFile(filePath):
            htmlUnicode, errString = _convertReST(text)
            return filePath, htmlUnicode, errString, QUrl()
        elif filePath and sphinxEnabledForFile(filePath):  # Use Sphinx to generate the HTML if possible.
            return self._sphinxConverter.convert(filePath)
        elif filePath and canUseCodeChat(filePath):  # Otherwise, fall back to using CodeChat+docutils.
            return _convertCodeChat(text, filePath)
        else:
            return filePath, 'No preview for this type of file', None, QUrl()

    def _copySphinxProjectTemplate(self, documentFilePath):
        """Add conf.py, CodeChat.css and index.rst (if ther're missing)
        to the Sphinx project directory.
        """
        if core.config()['Sphinx']['ProjectPath'] in self._sphinxTemplateCheckIgnoreList:
            return

        # Check for the existance Sphinx project files. Copy skeleton versions
        # of them to the project if necessary.
        sphinxPluginsPath = os.path.dirname(os.path.realpath(__file__))
        sphinxTemplatePath = os.path.join(sphinxPluginsPath, 'sphinx_templates')
        sphinxProjectPath = core.config()['Sphinx']['ProjectPath']
        errors = []
        checklist = ['index.rst', 'conf.py']
        if core.config()['CodeChat']['Enabled'] and CodeChat:
            checklist.append('CodeChat.css')
        missinglist = []
        for filename in checklist:
            if not os.path.exists(os.path.join(sphinxProjectPath, filename)):
                missinglist.append(filename)
        if not missinglist:
            return errors

        # For testing, check for test-provided button presses
        if ((len(self._sphinxTemplateCheckIgnoreList) == 1) and
                isinstance(self._sphinxTemplateCheckIgnoreList[0], int)):
            res = self._sphinxTemplateCheckIgnoreList[0]
        else:
            res = QMessageBox.warning(
                self,
                r"Enki",
                "Sphinx project at:\n " +
                sphinxProjectPath +
                "\nis missing the template file(s): " +
                ' '.join(missinglist) +
                ". Auto-generate those file(s)?",
                QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
                QMessageBox.Yes)
        if res != QMessageBox.Yes:
            if res == QMessageBox.No:
                self._sphinxTemplateCheckIgnoreList.append(sphinxProjectPath)
            return

        if core.config()['CodeChat']['Enabled'] and CodeChat:
            codeChatPluginsPath = os.path.dirname(os.path.realpath(CodeChat.__file__))
            codeChatTemplatePath = os.path.join(codeChatPluginsPath, 'template')
            copyTemplateFile(errors, codeChatTemplatePath, 'index.rst', sphinxProjectPath)
            copyTemplateFile(errors, codeChatTemplatePath, 'conf.py', sphinxProjectPath)
            copyTemplateFile(errors, codeChatTemplatePath, 'CodeChat.css', sphinxProjectPath)
        else:
            copyTemplateFile(errors, sphinxTemplatePath, 'index.rst', sphinxProjectPath)
            copyTemplateFile(errors, sphinxTemplatePath, 'conf.py', sphinxProjectPath)

        errInfo = ""
        for error in errors:
            errInfo += "Copy from " + error[0] + " to " + error[1] + " caused error " + error[2] + ';\n'
        if errInfo:
            QMessageBox.warning(self, "Sphinx template file copy error",
                                "Copy template project files failed. The following errors are returned:<br>"
                                + errInfo)

        return errors

    def _setHtmlFuture(self, future):
        """Receives a future and unpacks the result, calling _setHtml."""
        filePath, htmlText, errString, baseUrl = future.result
        self._setHtml(filePath, htmlText, errString, baseUrl)

    def _setHtml(self, filePath, htmlText, errString, baseUrl):
        """Set HTML to the view and restore scroll bars position.
        Called by the thread
        """

        self._saveScrollPos()
        self._visiblePath = filePath
        self._widget.webView.page().mainFrame().loadFinished.connect(
            self._restoreScrollPos)  # disconnected

        if baseUrl.isEmpty():
            # Clear the log, then update it with build content.
            self._widget.teLog.clear()
            self._widget.webView.setHtml(htmlText,
                                         baseUrl=QUrl.fromLocalFile(filePath))
        else:
            self._widget.webView.setUrl(baseUrl)

        # If there were messages from the conversion process, extract a count of
        # errors and warnings from these messages.
        if errString:
            # If there are errors/warnings, expand log window to make it visible
            if self._widget.splitterNormState:
                self._widget.splitterNormStateSize = self._widget.splitter.sizes()
                self._widget.splitterNormState = False
            self._widget.splitter.setSizes(self._widget.splitterErrorStateSize)

            # This code parses the error string to determine get the number of
            # warnings and errors. Common docutils error messages read::
            #
            #  <string>:1589: (ERROR/3) Unknown interpreted text role "ref".
            #
            #  X:\ode.py:docstring of sympy:5: (ERROR/3) Unexpected indentation.
            #
            # and common sphinx errors read::
            #
            #  X:\SVM_train.m.rst:2: SEVERE: Title overline & underline mismatch.
            #
            #  X:\indexs.rst:None: WARNING: image file not readable: a.jpg
            #
            #  X:\conf.py.rst:: WARNING: document isn't included in any toctree
            #
            # Each error/warning occupies one line. The following `regular
            # expression
            # <https://docs.python.org/2/library/re.html#regular-expression-syntax>`_
            # is designed to find the error position (1589/None) and message
            # type (ERROR/WARNING/SEVERE). Extra spaces are added to show which
            # parts of the example string it matches. For more details about
            # Python regular expressions, refer to the
            # `re docs <https://docs.python.org/2/library/re.html>`_.
            #
            # Examining this expression one element at a time::
            #
            #   <string>:1589:        (ERROR/3)Unknown interpreted text role "ref".
            errPosRe = ':(\d*|None): '
            # Find the first occurence of a pair of colons.
            # Between them there can be numbers or "None" or nothing. For example,
            # this expression matches the string ":1589:" or string ":None:" or
            # string "::". Next::
            #
            #   <string>:1589:        (ERROR/3)Unknown interpreted text role "ref".
            errTypeRe = '\(?(WARNING|ERROR|SEVERE)'
            # Next match the error type, which can
            # only be "WARNING", "ERROR" or "SEVERE". Before this error type the
            # message may optionally contain one left parenthesis.
            #
            errEolRe = '.*$'
            # Since one error message occupies one line, a ``*``
            # quantifier is used along with end-of-line ``$`` to make sure only
            # the first match is used in each line.
            #
            # TODO: Is this necesary? Is there any case where omitting this
            # causes a failure?
            regex = re.compile(errPosRe + errTypeRe + errEolRe,
                               # The message usually contain multiple lines; search each line
                               # for errors and warnings.
                               re.MULTILINE)
            # Use findall to return all matches in the message, not just the
            # first.
            result = regex.findall(errString)

            # The variable ``result`` now contains a list of tuples, where each
            # tuples contains the two matched groups (line number, error_string).
            # For example::
            #
            #  [('1589', 'ERROR')]
            #
            # Therefeore, the second element of each tuple, represented as x[1],
            # is the error_string. The next two lines of code will collect all
            # ERRORs/SEVEREs and WARNINGs found in the error_string separately.
            errNum = sum([x[1] == 'ERROR' or x[1] == 'SEVERE' for x in result])
            warningNum = [x[1] for x in result].count('WARNING')
            # Report these results this to the user.
            status = 'Error(s): {}, warning(s): {}'.format(errNum, warningNum)
            # Since the error string might contain characters such as ">" and "<",
            # they need to be converted to "&gt;" and "&lt;" such that
            # they can be displayed correctly in the log window as HTML strings.
            # This step is handled by ``html.escape``.
            self._widget.teLog.appendHtml("<pre><font color='red'>\n" +
                                          html.escape(errString) +
                                          '</font></pre>')
            # Update the progress bar.
            color = 'red' if errNum else '#FF9955' if warningNum else None
            self._setHtmlProgress(status, color)
        else:
            # If there are no errors/warnings, collapse the log window (can mannually
            # expand it back to visible)
            if not self._widget.splitterNormState:
                self._widget.splitterErrorStateSize = self._widget.splitter.sizes()
                self._widget.splitterNormState = True
            self._widget.splitter.setSizes(self._widget.splitterNormStateSize)
            self._setHtmlProgress('Error(s): 0, warning(s): 0')

        # Do a rebuild if needed.
        if self._rebuildNeeded:
            self._rebuildNeeded = False
            self._scheduleDocumentProcessing()

    def _setHtmlProgress(self, text, color=None):
        """Set progress label.
        """
        if color:
            style = 'QLabel { background-color: ' + color + '; }'
        else:
            style = style = 'QLabel {}'
        self._widget.prgStatus.setStyleSheet(style)
        self._widget.prgStatus.setText(text)