diff --git a/CodeChat_Settings.ui b/CodeChat_Settings.ui new file mode 100644 index 0000000..722a331 --- /dev/null +++ b/CodeChat_Settings.ui @@ -0,0 +1,66 @@ + + + Form + + + + 0 + 0 + 958 + 687 + + + + Form + + + + + + <html><head/><body><p><span style=" color:#ff0000;">The CodeChat package is not installed or not found</span>. <a href="https://pythonhosted.org/CodeChat/install.html"><span style=" text-decoration: underline; color:#0000ff;">Install it</span></a>.</p></body></html> + + + true + + + + + + + <html><head/><body><p>CodeChat is a conversational coding system that encourages literate programming by transforming source code to HTML including hyperlinks, images, and much more. This web page produced by CodeChat is viewable in the Preview dock.</p><p>Refer to the CodeChat <a href="http://pythonhosted.org/CodeChat/README.html"><span style=" text-decoration: underline; color:#0000ff;">documentation</span></a> for more details.</p></body></html> + + + true + + + true + + + + + + + Enable CodeChat + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + diff --git a/Preview.ui b/Preview.ui new file mode 100644 index 0000000..c75fa00 --- /dev/null +++ b/Preview.ui @@ -0,0 +1,155 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + + + + + + 51 + 153 + 255 + + + + + + + + + 255 + 255 + 153 + + + + + + + + + 51 + 153 + 255 + + + + + + + + Form + + + + + + Qt::Vertical + + + + + + + + + 0 + 50 + + + + + 16777215 + 16777215 + + + + + Courier New + + + + true + + + true + + + + + + + + + + Template + + + cbTemplate + + + + + + + + + + Enable JS + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Save + + + + :/enkiicons/save.png:/enkiicons/save.png + + + + + + + + + + QWebView + QWidget +
QtWebKit/QWebView
+
+
+ + + + +
diff --git a/Sphinx_Settings.ui b/Sphinx_Settings.ui new file mode 100644 index 0000000..55cdf61 --- /dev/null +++ b/Sphinx_Settings.ui @@ -0,0 +1,284 @@ + + + Form + + + + 0 + 0 + 958 + 687 + + + + Form + + + + + + <html><head/><body><p><a href="http://sphinx-doc.org"><span style=" text-decoration: underline; color:#0000ff;">Sphinx</span></a> is a tool that makes it easy to create intelligent and beautiful documentation.</p></body></html> + + + true + + + true + + + + + + + true + + + Enable Sphinx project + + + true + + + + + + + + Project path + + + + + + + + + + ... + + + + + + + + + <html><head/><body><p><span style=" font-style:italic;">Hint: The project path usually contains </span><span style=" font-family:'Courier New,courier';">conf.py</span><span style=" font-style:italic;">.</span></p></body></html> + + + + + + + + + Source path + + + + + + + + + + ... + + + + + + + + + + + Output path + + + + + + + + + + ... + + + + + + + + + + + Command line arguments + + + + + + + + + + + + + + + + + + <a href="http://sphinx-doc.org/invocation.html"><span style=" text-decoration: underline; color:#0000ff;">Reference</span></a> + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + true + + + + + + + Qt::Horizontal + + + + 287 + 17 + + + + + + + + PointingHandCursor + + + <html><head/><body><p><span style=" text-decoration: underline; color:#0000ff;">Switch to Advanced Mode</span></p></body></html> + + + + + + + + + + + + Sphinx executable path + + + + + + + + + ... + + + + + + + + + + + + 255 + 0 + 0 + + + + + + + + + 255 + 0 + 0 + + + + + + + + + 158 + 158 + 158 + + + + + + + + To be set + + + + + + + + + + Rebuild Sphinx projects + + + + + + Only after the file was saved + + + true + + + + + + + Qt::LeftToRight + + + Auto-save and build on any file change + + + + + + + + + + Qt::Vertical + + + + 20 + 328 + + + + + + + + + + + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4188262 --- /dev/null +++ b/__init__.py @@ -0,0 +1,584 @@ +# ********************************************************** +# __init__.py - The HTML, Markdown, and reST preview package +# ********************************************************** +# The Preview plugin provides an HTML-based rendering of the +# file currently being edited. This file implements the +# Plugin interface; other modules are given below. +"""Preview plugin for HTML based documents, like HTML, Markdown, reStructuredText, Sphinx and CodeChat""" +__pluginname__ = "Preview" +__author__ = "Marco Laspe" +__credits__ = ["Andrei Kopats", "Bryan A. Jones"] +__license__ = "GPL3" +__version__ = "0.1.0" +__maintainer__ = "Marco Laspe" +__email__ = "marco@rockiger.com" +__status__ = "Beta" +# This plugin is a copy of the Enki preview plugin +# https://github.com/andreikop/enki/tree/master/enki/plugins/preview + +# +# Imports +# ======= +# These are listed in the order prescribed by `PEP 8 +# `_. +# +# Library imports +# --------------- +import sys +import os.path +import fnmatch +import types +# +# Third-party imports +# ------------------- +from PyQt5.QtCore import QObject, Qt +from PyQt5.QtWidgets import QAction, QWidget, QFileDialog, QLabel +from PyQt5.QtGui import QIcon, QKeySequence, QPalette +from PyQt5 import uic +import sip +# +# Local imports +# ------------- +from enki.core.core import core +from enki.core.uisettings import CheckableOption, TextOption, ChoiseOption +from enki.lib.get_console_output import get_console_output +from enki.widgets.dockwidget import DockWidget + +# Constants +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Import CodeChat if possible; otherwise, indicate it wasn't available. +try: + from CodeChat import CodeToRest + from CodeChat.CommentDelimiterInfo import SUPPORTED_GLOBS +except ImportError: + CodeToRest = None + +# Utilities +# ========= +def isHtmlFile(document): + """Return True if document refers to an HTML file; return False otherwise. + """ + return (document is not None and + document.qutepart.language() is not None and + 'html' in document.qutepart.language().lower() and + # Return True if this is a Django HTML template; + # return False for HTML (PHP). + (not 'php' in document.qutepart.language().lower())) + + +def canUseCodeChat(filePath): + """Return True if CodeChat can be used with ``filePath``; return False + otherwise. + """ + # CodeChat can preview a file if it's enabled and if that file's + # name/extension is supported. + if (CodeToRest is not None and core.config()['CodeChat']['Enabled'] + and filePath): + filename = os.path.basename(filePath) + for glob in SUPPORTED_GLOBS: + if fnmatch.fnmatch(filename, glob): + return True + return False + + +def sphinxEnabledForFile(filePath): + """Based on Sphinx settings under core.config()['Sphinx'], this function + determines whether Sphinx can be applied to filePath. It can't know if + Sphinx actually processes the file or not, since this is based on conf.py + settings. + """ + sphinxProjectPath = core.config()['Sphinx']['ProjectPath'] + return (filePath and + core.config()['Sphinx']['Enabled'] and + os.path.exists(core.config()['Sphinx']['ProjectPath']) and + os.path.normcase(sphinxProjectPath) == + commonPrefix(filePath, sphinxProjectPath)) + + +def commonPrefix(*dirs): + """This function provides a platform-independent path commonPrefix. It + returns the common path between all directories in input list dirs, assuming + that any relative paths are rooted in the current directory. While `this post + `_ + has two solutions, neither are correct; hence, the following code. + + Parameters: dirs - Directory list. + Return value: the common path prefix shared by all input directories. + """ + # corner case (empty input list) + if not dirs: + return '' + # Path cleaning toolset: + # + # - **realpath** follows symbolic links, so they will be compared in + # terms of what they refer to. realpath will also evaluate directory + # traversals. + # + # #. get Canonical path. + # #. eliminate symbolic links. + # + # - **normcase** makes Windows filenames all lowercase, since the + # following code uses case-sensitive string comparions. Windows + # uses case-insensitive naming for its files. + # + # #. converts path to lower case for case-insensitive filesystem. + # #. converts forward slashes to backward slashes (Windows only) + # - **abspath** collapses and evaluates directory traversals like + # ``./../subdir``, to correctly compare absolute and relative paths, + # and normalizes the os.path.sep for the current platform + # (i.e. no `\a/b` paths). Similar to ``normpath(join(os.getcwd(), + # path))``. + fullPathList = [os.path.normcase(os.path.abspath(os.path.realpath(d))) + for d in dirs] + # Now use ``commonprefix`` on absolute paths. + prefix = os.path.commonprefix(fullPathList) + # commonprefix stops at the first dissimilar character, leaving an + # incomplete path name. For example, ``commonprefix(('aa', 'ab')) == 'a'``. + # Fix this by removing this ending incomplete path if necessary. + for d in fullPathList: + # ``commonPrefix`` contains a complete path if the character in + # ``d`` after its end is an os.path.sep or the end of the path name. + if len(d) > len(prefix) and d[len(prefix)] != os.path.sep: + # This is an incomplete path. Remove it. + prefix = os.path.dirname(prefix) + break + # If any input directory is absolute path, then commonPrefix will return + # an absolute path. + if any((os.path.isabs(d)) for d in dirs): + return prefix + + # If not, we will use the assumption that all relative paths + # are rooted in the current directory. Test whether ``prefix`` starts with + # the current working directory. If not, return an absolute path. + cwd = os.path.normcase(os.getcwd()) + return prefix if not prefix.startswith(cwd) \ + else prefix[len(cwd) + len(os.path.sep):] + + +def _getSphinxVersion(path): + """Return the Sphinx version as a list of integer items. + + Raise OSError if not found, or + ValueError if failed to parse. + """ + stdout, stderr = get_console_output([path, "--version"]) + # Command "Sphinx-build --version" will only output sphinx version info. + # Typical output looks like: ``Sphinx (sphinx-build) 1.2.3`` or + # ``Sphinx v1.2.3`` + # But the problem is sometimes version info goes to stdout(version 1.2.3), + # while sometimes it goes to stderr(version 1.1.3). Thus combining stdout + # and stderr is necessary. + out = stdout + '\n' + stderr + for line in out.split('\n'): + if line.startswith("Sphinx"): + # Split on space, take the last segment, if it starts with character + # 'v', strip the 'v'. Then split on dot. returning the version as a + # tuple. + version = line.split(' ')[-1] + version = version[1:] if version.startswith('v') else version + return [int(num) for num in version.split('.')] + raise ValueError + +# GUIs +# ==== +# This class implements the GUI for a combined CodeChat settings page. +class CodeChatSettingsWidget(QWidget): + """Insert the preview plugin as a page of the UISettings dialog. + """ + + def __init__(self, settingsPage, dialog): + # Initialize the dialog, loading in the literate programming settings + # GUI. + QWidget.__init__(self, dialog) + uic.loadUi(os.path.join(os.path.dirname(__file__), + 'CodeChat_Settings.ui'), self) + + if CodeToRest is None: + # If the CodeChat module can't be loaded, then disable the + # associated checkbox and show the "not installed" message. + self.cbCodeChat.setEnabled(False) + self.labelCodeChatNotInstalled.setVisible(True) + self.labelCodeChatNotInstalled.setEnabled(True) + else: + # Hide the "not installed" message. + self.labelCodeChatNotInstalled.setVisible(False) + + # Add this GUI to the settings dialog box. + settingsPage.addWidget(QLabel("

Literate programming

")) + settingsPage.addWidget(self) + # Next, have the setting UI auto-update the corresponding CodeChat and + # config entries. + dialog.appendOption(CheckableOption(dialog, core.config(), + "CodeChat/Enabled", + self.cbCodeChat)) + + +# This class implements the GUI for a combined CodeChat / Sphinx settings page. +class SphinxSettingsWidget(QWidget): + """Insert the preview plugin as a page of the UISettings dialog. + """ + + def __init__(self, settingsPage, dialog): + # Initialize the dialog, loading in the literate programming settings + # GUI. + QWidget.__init__(self, dialog) + uic.loadUi(os.path.join(os.path.dirname(__file__), + 'Sphinx_Settings.ui'), self) + + # Make links gray when they are disabled + palette = self.palette() + palette.setColor(QPalette.Disabled, + QPalette.Link, + palette.color(QPalette.Disabled, QPalette.Text)) + self.lbSphinxReference.setPalette(palette) + + palette = self.palette() + palette.setColor(QPalette.Active, + QPalette.WindowText, + palette.color(QPalette.Normal, QPalette.Link)) + self.lbSphinxEnableAdvMode.setPalette(palette) + + # Clicking on advanced mode label triggers either advanced mode or + # normal mode. + self.lbSphinxEnableAdvMode.mousePressEvent = self.on_ToggleSphinxSettingModeClicked + + # Update misc pieces of the GUI that can't be stored in the .ui file. + self._updateSphinxSettingMode() + + # Add this GUI to the settings dialog box. + settingsPage.addWidget(QLabel("

Sphinx

")) + settingsPage.addWidget(self) + # Next, have the setting UI auto-update the corresponding CodeChat and + # config entries. + dialog.appendOption(CheckableOption(dialog, core.config(), + "Sphinx/Enabled", + self.gbSphinxProject)) + dialog.appendOption(ChoiseOption(dialog, core.config(), + "Sphinx/BuildOnSave", + {self.rbBuildOnlyOnSave: True, + self.rbBuildOnFileChange: False})) + dialog.appendOption(TextOption(dialog, core.config(), + "Sphinx/ProjectPath", + self.leSphinxProjectPath)) + dialog.appendOption(TextOption(dialog, core.config(), + "Sphinx/SourcePath", + self.leSphinxSourcePath)) + dialog.appendOption(TextOption(dialog, core.config(), + "Sphinx/OutputPath", + self.leSphinxOutputPath)) + dialog.appendOption(TextOption(dialog, core.config(), + "Sphinx/Executable", + self.leSphinxExecutable)) + dialog.appendOption(TextOption(dialog, core.config(), + "Sphinx/Cmdline", + self.leSphinxCmdline)) + + # Run this after the appendOption calls, since these fields must be set + # up before _updateleValidateSphinxExecutable can run. + self._updateleValidateSphinxExecutable() + + def _updateleValidateSphinxExecutable(self): + """ Check if Sphinx is installed. Sphinx version is not important + + Update leValidateSphinxExecutable based on Sphinx status. + """ + path = self.leSphinxExecutable.text() + try: + _getSphinxVersion(path) + except OSError as ex: + self.leValidateSphinxExecutable.setText('Failed to execute {}: {}'.format(path, ex)) + except ValueError: + self.leValidateSphinxExecutable.setText('Failed to parse {} version. Does sphinx work?'.format(path)) + else: + self.leValidateSphinxExecutable.setText('Sphinx is found!') + + def on_pbSphinxProjectPath_clicked(self): + """Provide a directory chooser for the user to select a project path. + """ + path = QFileDialog.getExistingDirectory(core.mainWindow(), + 'Project path', self.leSphinxProjectPath.text()) + if path: + self.leSphinxProjectPath.setText(path) + # Automatically set the builder output path to '_build\\html' under + # builder root path. + # + # Since relative paths are supported, we will only set + # leSphinxOutputPath if the path was none or was absolute (and + # therefore presumabely wrong). If it's a relative path such as + # ``_build\html``, then it's probably OK without changing. + if (not self.leSphinxOutputPath.text() + or os.path.isabs(self.leSphinxOutputPath.text())): + self.leSphinxOutputPath.setText(os.path.join(path, '_build', + 'html')) + + def on_tbSphinxSourcePath_clicked(self): + """Provide a directory chooser for the user to select a source path. + """ + path = QFileDialog.getExistingDirectory(core.mainWindow(), 'Source path', self.leSphinxSourcePath.getText()) + if path: + self.leSphinxSourcePath.setText(path) + + def on_pbSphinxOutputPath_clicked(self): + """Provide a directory chooser for the user to select an output path. + """ + path = QFileDialog.getExistingDirectory(core.mainWindow(), 'Output path', self.leSphinxOutputPath.getText()) + if path: + self.leSphinxOutputPath.setText(path) + + # The Sphinx executable can be selected by the user. A filter is needed + # such that non-executable files will not be selected by the user. + def on_pbSphinxExecutable_clicked(self): + fltr = "sphinx-build" + (".exe" if sys.platform.startswith("win") else "") \ + + ";; All files (*)" + path, _ = QFileDialog.getOpenFileName(self, + "Select Sphinx executable", + filter=fltr) + if path: + self.leSphinxExecutable.setText(path) + self._updateleValidateSphinxExecutable() + + def on_ToggleSphinxSettingModeClicked(self, *args): + core.config()['Sphinx']['AdvancedMode'] = not core.config()['Sphinx']['AdvancedMode'] + core.config().flush() + self._updateSphinxSettingMode() + + # The project path and Sphinx executable directory must be absolute; + # the source and output paths may be relative to the project path or absolute. + # Use abspath or normpath as appropriate to guarantee this is true. + def on_leSphinxProjectPath_editingFinished(self): + self.leSphinxProjectPath.setText(os.path.abspath(self.leSphinxProjectPath.text())) + + def on_leSphinxOutputPath_editingFinished(self): + self.leSphinxOutputPath.setText(os.path.normpath(self.leSphinxOutputPath.text())) + + def on_leSphinxExecutable_editingFinished(self): + self._updateleValidateSphinxExecutable() + + def _updateSphinxSettingMode(self): + """Update the Sphinx settings mode by hiding/revealing the appropriate + controls. + """ + if core.config()['Sphinx']['AdvancedMode']: + # Switch to advanced setting mode: + # hide all path setting line edit boxes and buttons. + self.gbSphinxExecutable.setVisible(False) + # Enable advanced setting mode items + self.lbSphinxEnableAdvMode.setText('

' + + 'Switch to Normal Mode' + + '

') + self.lbSphinxCmdline.setVisible(True) + self.leSphinxCmdline.setVisible(True) + self.lbSphinxReference.setVisible(True) + else: + # Reenable all path setting line edit boxes and buttons + self.gbSphinxExecutable.setVisible(True) + # Hide all advanced mode entries. + self.lbSphinxEnableAdvMode.setText('

' + + 'Switch to Advanced Mode' + + '

') + self.lbSphinxCmdline.setVisible(False) + self.leSphinxCmdline.setVisible(False) + self.lbSphinxReference.setVisible(False) + + +class NoWebkitDock(DockWidget): + def __init__(self): + DockWidget.__init__(self, core.mainWindow(), "Previe&w", QIcon.fromTheme('applications-internet'), "Alt+W") + self._widget = QLabel("Qt5 WebEngine not found. Preview is not available.
" + "Run pip install PyQt5.") + self.setFocusProxy(self._widget) + self.setWidget(self._widget) + + def terminate(self): + pass + + +try: + # See if this supports the ``scrollPosition`` method introduced in Qt 5.7. + from PyQt5.QtWebEngineWidgets import QWebEnginePage + assert type(QWebEnginePage.scrollPosition) == types.BuiltinMethodType + haveWebEngine = True +except (ImportError, AssertionError): + haveWebEngine = False + + +# Plugin +# ====== +# This class integrates the preview dock into Enki. Specifically, it: +# +# #. Adds the GUI defined above to the Settings dialog box +class Plugin(QObject): + """Plugin interface implementation. + """ + + def __init__(self): + """Create and install the plugin + """ + QObject.__init__(self) + + self._dock = None + self._saveAction = None + self._dockInstalled = False + core.workspace().currentDocumentChanged.connect(self._onDocumentChanged) + core.workspace().languageChanged.connect(self._onDocumentChanged) + + # Install our CodeChat page into the settings dialog. + core.uiSettingsManager().aboutToExecute.connect(self._onSettingsDialogAboutToExecute) + # Update preview dock when the settings dialog (which contains the + # CodeChat enable checkbox) is changed. + core.uiSettingsManager().dialogAccepted.connect(self._onDocumentChanged) + + # Provide a "Set Sphinx Path" menu item. + core.uiSettingsManager().dialogAccepted.connect(self._setSphinxActionVisibility) + self._sphinxAction = QAction('Set Sphinx path', self._dock) + self._sphinxAction.setShortcut(QKeySequence('Alt+Shift+S')) + core.actionManager().addAction('mPlugins/aSetSphinxPath', + self._sphinxAction) + self._sphinxAction.triggered.connect(self.onSphinxPath) + # Only enable this command if the File browser has a valid path. + self.onFileBrowserPathChanged() + core.project().changed.connect(self.onFileBrowserPathChanged) + + # If user's config .json file lacks it, populate CodeChat's default + # config keys and Sphinx's default config keys. + c = core.config() + c.setdefault('CodeChat', {}) + c.setdefault('CodeChat/Enabled', False) + c.setdefault('Sphinx', {}) + c.setdefault('Sphinx/Enabled', False) + c.setdefault('Sphinx/Executable', 'sphinx-build') + c.setdefault('Sphinx/ProjectPath', '') + c.setdefault('Sphinx/SourcePath', '.') + c.setdefault('Sphinx/BuildOnSave', False) + c.setdefault('Sphinx/OutputPath', os.path.join('_build', + 'html')) + c.setdefault('Sphinx/AdvancedMode', False) + c.setdefault('Sphinx/Cmdline', ('sphinx-build -d ' + + os.path.join('_build', 'doctrees') + ' . ' + + os.path.join('_build', 'html'))) + core.config().flush() + + self._setSphinxActionVisibility() + + def terminate(self): + """Uninstall the plugin + """ + core.actionManager().removeAction('mPlugins/aSetSphinxPath') + + if self._dockInstalled: + self._removeDock() + + if self._dock is not None: + self._dock.terminate() + + sip.delete(self) + + + def _onDocumentChanged(self): + """Document or Language changed. + Create dock, if necessary + """ + if self._canPreview(core.workspace().currentDocument()): + if not self._dockInstalled: + self._createDock() + else: + if self._dockInstalled: + self._removeDock() + + def _canPreview(self, document): + """Check if the given document can be shown in the Preview dock. + """ + if document is None: + return False + + if document.qutepart.language() in ('Markdown', 'reStructuredText') or \ + isHtmlFile(document): + return True + + if canUseCodeChat(document.filePath()): + return True + + if sphinxEnabledForFile(document.filePath()): + return True + + return False + + def _createDock(self): + """Install dock + """ + # create dock + if self._dock is None: + if haveWebEngine: + from .preview import PreviewDock + self._dock = PreviewDock() + + self._saveAction = QAction(QIcon(':enkiicons/save.png'), + 'Save Preview as HTML', self._dock) + self._saveAction.setShortcut(QKeySequence("Alt+Shift+P")) + self._saveAction.triggered.connect(self._dock.onPreviewSave) + else: + self._dock = NoWebkitDock() + + if haveWebEngine: + core.actionManager().addAction("mFile/aSavePreview", self._saveAction) + + self._dock.closed.connect(self._onDockClosed) + self._dock.shown.connect(self._onDockShown) + core.mainWindow().addDockWidget(Qt.RightDockWidgetArea, self._dock) + + core.actionManager().addAction("mView/aPreview", + self._dock.showAction()) + self._dockInstalled = True + if core.config()['Preview']['Enabled']: + self._dock.show() + + def _onDockClosed(self): + """Dock has been closed by user. Change Enabled option + """ + if core.config()['Preview']['Enabled']: + core.config()['Preview']['Enabled'] = False + core.config().flush() + + def _onDockShown(self): + """Dock has been shown by user. Change Enabled option + """ + if not core.config()['Preview']['Enabled']: + core.config()['Preview']['Enabled'] = True + core.config().flush() + + def _removeDock(self): + """Remove dock from GUI + """ + if haveWebEngine: + core.actionManager().removeAction("mFile/aSavePreview") + + core.actionManager().removeAction("mView/aPreview") + core.mainWindow().removeDockWidget(self._dock) + self._dockInstalled = False + + def _onSettingsDialogAboutToExecute(self, dialog): + """The UI settings dialog is about to execute. Install preview-related + settings.""" + settingsPage = dialog.createSettingsPage("Preview", dialog) + iconPath = os.path.join(PLUGIN_DIR, 'view-preview.svg') + dialog.appendPage("Preview", settingsPage, + QIcon.fromTheme("view-preview", QIcon(iconPath))) + CodeChatSettingsWidget(settingsPage, dialog) + SphinxSettingsWidget(settingsPage, dialog) + + def _setSphinxActionVisibility(self): + self._sphinxAction.setVisible(core.config()['Sphinx']['Enabled']) + + def onFileBrowserPathChanged(self): + """Enable the onSphinxPath command only when there's a valid project + path.""" + self._sphinxAction.setEnabled(bool(core.project().path())) + + def onSphinxPath(self): + """Set the Sphinx path to the current project path.""" + assert core.project().path() + core.config()['Sphinx']['ProjectPath'] = core.project().path() + core.config().flush() + if core.config()['Preview']['Enabled'] and self._dock is not None: + self._dock._scheduleDocumentProcessing() diff --git a/approx_match.py b/approx_match.py new file mode 100644 index 0000000..7551519 --- /dev/null +++ b/approx_match.py @@ -0,0 +1,450 @@ +# .. -*- coding: utf-8 -*- +# ************************************************************************************** +# approx_match.py - provide approximate matching to support code and web synchronization +# ************************************************************************************** +# The findApproxTextInTarget_ function in this module searches a target string +# for the best match to characters about an anchor point in a source string. In +# particular, it first locates a block of target text which forms the closest +# approximate match for source characters about the anchor. Then, it looks for +# the (almost) longest possible exact match between source characters about the +# anchor and the block of target text found in the first step. +# +# Imports +# ======= +# These are listed in the order prescribed by `PEP 8 +# `_. +# +# Library imports +# --------------- +# For debugging. +import codecs +import html +import os +# +# Third-party imports +# ------------------- +import pkg_resources + +# The `regex `_ module supports approximate +# matching. Make sure it is recent enough to be usable. +import regex +try: + # Get the version of regex. See https://pythonhosted.org/setuptools/pkg_resources.html#distribution-attributes. + regexVersion = pkg_resources.get_distribution('regex').parsed_version + # Issues I filed before this make regex unusable. See #166, #167, andd #169 at + # https://bitbucket.org/mrabarnett/mrab-regex/issues. For version parse, see + # https://pythonhosted.org/setuptools/pkg_resources.html#parsing-utilities. + assert regexVersion >= pkg_resources.parse_version('2015.11.07') +except AssertionError as ValueError: + raise ImportError +# +# For debug +# ========= +# Write the results of a match to an HTML file if enabled. +ENABLE_LOG = False +# +# Given a search result, format it in HTML: create a
 entry with the text,
+# hilighting from the leftAnchor to the searchAnchor in one color and from
+# searchAnchor to rightAnchor in another color. Show the anchor with a big
+# yellow X marks the spot.
+def htmlFormatSearchInput(searchText, leftAnchor, searchAnchor, rightAnchor,
+                          showX=True):
+    # Divide the text into four pieces based on the three anchors. Escape them
+    # for use in HTML.
+    beforeLeft = html.escape(searchText[:leftAnchor])
+    leftToAnchor = html.escape(searchText[leftAnchor:searchAnchor])
+    anchorToRight = html.escape(searchText[searchAnchor:rightAnchor])
+    afterRight = html.escape(searchText[rightAnchor:])
+
+    return ( (
+      # Use preformatted text so spaces, newlines get
+      # interpreted correctly. Include all text up to the
+      # left anchor.
+      '
%s' +
+      # Format text between the left anchor and the search
+      # anchor with a red background.
+      '%s' +
+      # Place a huge X marks the spot at the anchor
+      ('X' if showX else '') +
+      # Format text between the search anchor and the right
+      # anchor with a yellow background.
+      '%s' +
+      # Include the text between the right anchor and end of
+      # text with no special formatting.
+      '%s
') % (beforeLeft, leftToAnchor, anchorToRight, afterRight) ) + +# Take these two results and put them side by side in a table. +def htmlFormatSearch(htmlSearchInput, htmlSearchResults, resultText): + return ( ( + # Preserve white space in the resultText string. + '%s
\n\n' + + '\n' + '\n' + + '
Search inputSearch results
%s%s
\n' + + '


' + ) % + (resultText, htmlSearchInput, htmlSearchResults) ) + + +# Create text for a simple web page. +LOG_COUNTER = 0 +def htmlTemplate(body): + global LOG_COUNTER + + LOG_COUNTER += 1 + return ( ( + """ + ApproxMatch log #%d + + + %s + """) % (LOG_COUNTER, body) ) + +# Given HTML, write it to a file. +def writeHtmlLog(htmlText): + print(("Writing log file to " + os.getcwd())) + with codecs.open('approx_match_log.html', 'w', encoding='utf-8') as f: + f.write(htmlText) +# +# findApproxText +# ============== +# This function performs a single approximate match using the regex_ library. +# +# Return value: +# - If there is no unique value, (None, 0, 0) +# - Otherwise, it returns (match, beginInTarget, endInTarget) where: +# +# match +# A regex_ match object. +# +# beginInTarget +# The index into the target string at which the approximate match begins. +# +# endInTarget +# The index into the target string at which the approximate match ends. +def findApproxText( + # Text to search for + searchText, + # Text in which to find the searchText + targetText): + + mo = regexFuzzySearch(searchText, targetText) + if mo: + # See if this match is unique enough by looking for the next best match + # by searching in the string before then the string after the match. + moPre = regexFuzzySearch(searchText, targetText[:mo.start()]) + moPost = regexFuzzySearch(searchText, targetText[mo.end():]) + + # Compute an error based on the fuzzyness. If there wasn't a match, use + # maximum error of moError + 6, so that the test below passes. + moError = sum(mo.fuzzy_counts) + moPreError = sum(moPre.fuzzy_counts) if moPre else moError*2 + moPostError = sum(moPost.fuzzy_counts) if moPost else moError*2 + + # Make sure the difference between the match and any other match is high + # enough to consider this match unique. + if moError*1.1 <= moPreError and moError*1.1 <= moPostError: + return mo + + # If a match couldn't be found or wasn't good enough, return a failure. + return None + +# This helper function uses regex_ to perform a fuzzy search. +def regexFuzzySearch( + # Text to search for. It is NOT treated as a regex. + searchText, + # Text in which to find the searchText + targetText): + + # Escape any characters in searchText that would be treated as a regexp. + searchText = regex.escape(searchText) + # The regex_ library supports fuzzy matching. Quoting from the manual: + # + # - ``(item){e}`` means perform a fuzzy match of the given ``item``, + # allowing insertions, deletions, or substitutions. + # - The BESTMATCH flag searches for the best possible match, rather than the + # match found first. + return regex.search('(' + searchText + '){e}', targetText, regex.BESTMATCH) + +# +# findApproxTextInTarget +# ====================== +# This routine first finds the closest approximate match of a substring centered +# around the searchAnchor in the targetText. +# +# Return value: An (almost) exactly-matching location in the target document, or +# -1 if not found. +def findApproxTextInTarget( + # The text composing the entire source document in + # which the search string resides. + searchText, + # A location in the source document which should be + # found in the target document. + searchAnchor, + # The target text in which the search will be performed. + targetText, + # The radius of the substring around the searchAnchor + # that will be approximately matched in the + # targetText: a value of 10 produces a length-20 + # substring (10 characters before the anchor, and 10 + # after). + searchRange=30): + + # Look for the best approximate match within the targetText of the source + # substring composed of characters within a radius of the anchor. + begin = max(0, searchAnchor - searchRange) + end = min(len(searchText), searchAnchor + searchRange) + # Empty documents are easy to search. + if end <= begin: + return 0 + # Look for a match. + # record left and right search radii. + mo = findApproxText(searchText[begin:end], targetText) + # If no unique match is found, try again with an increased search radius. + if not mo: + begin = max(0, searchAnchor - int(searchRange * 1.5)) + end = min(len(searchText), searchAnchor + int(searchRange * 1.5)) + mo = findApproxText(searchText[begin:end], targetText) + if not mo: + if ENABLE_LOG: + si = htmlFormatSearchInput(searchText, begin, searchAnchor, end) + sr = htmlFormatSearchInput(targetText, 0, 0, 0) + fs = htmlFormatSearch(si, sr, "No unique match found.") + ht = htmlTemplate(fs) + writeHtmlLog(ht) + return -1 + if ENABLE_LOG: + # Log the initial match results + si = htmlFormatSearchInput(searchText, begin, searchAnchor, end) + sr = htmlFormatSearchInput(targetText, mo.start(), mo.start(), + mo.end(), False) + fs = htmlFormatSearch(si, sr, "Initial fuzzy search results") + + # Get a search and target substring from the match. + searchPattern = searchText[begin:end] + targetSubstring = targetText[mo.start():mo.end()] + # Use the LCS_ algorithm to perform a more exact match. This algorithm + # runs in O(NM) time, compared to regex's compiled (and hopefully faster) + # performance. + relativeSearchAnchor = searchAnchor - begin + offset, lcsString = refineSearchResult(searchPattern, relativeSearchAnchor, + targetSubstring, ENABLE_LOG) + if offset != -1: + offset = offset + mo.start() + + if ENABLE_LOG: + si = htmlFormatSearchInput(searchText, begin, searchAnchor, end) + if offset is not -1: + sr = htmlFormatSearchInput(targetText, mo.start(), offset, + mo.end()) + fs += htmlFormatSearch(si, sr, "Match was '%s'" % lcsString) + else: + sr = htmlFormatSearchInput(targetText, 0, 0, 0) + fs += htmlFormatSearch(si, sr, "No unique match found.") + ht = htmlTemplate(fs) + writeHtmlLog(ht) + + return offset +# +# refineSearchResult +# ================== +# This function performs identically to findApproxTextInTarget_, but uses a more +# expensive and expact algorithm to compute the result. +def refineSearchResult( + # The text composing the entire source document in + # which the search string resides. + searchText, + # A location in the source document which should be + # found in the target document. + searchAnchor, + # The target text in which the search will be performed. + targetText, + # True to return part of the resulting string as well; otherwise, the returned + # lcsString will be empty. To get the full LCS string returned, pass + # searchAnchor = 0. Used for testing. + returnLcsString=False): + # + # Find the longest common substring (`LCS + # `_ + # between the source and target strings. The code was adopted from + # `Rosettacode `_. + # + # A note on indices used in this algorithm:: + # + # The string s = abc: a b c + # Python string index (e.g. s[n]): 0 1 2 + # LCS table index (e.g. x or y = n): 1 2 3 + # Qt cursor anchor (e.g. searchAnchor = n): 0 1 2 3 + # + # So, a given x or y value refers to a table index or, equivalently, an + # anchor to their right. + # + # Initialize the substring length table entries to 0. + lengths = [[0 for j in range(len(targetText) + 1)] + for i in range(len(searchText) + 1)] + + # Determine the length of the longest common subsequence and store this in + # the table. + for i, x in enumerate(searchText): + for j, y in enumerate(targetText): + # When characters match, increase the substring length. Otherwise, + # the use maximum substring length found thus far. + if x == y: + lengths[i + 1][j + 1] = lengths[i][j] + 1 + else: + lengths[i + 1][j + 1] = max(lengths[i + 1][j], lengths[i][j + 1]) + + # If LCS fails to find a common subsequence, then set the offset to -1 and + # inform ``findApproxTextInTarget`` that no match is found. This rarely + # happens since regex has preprocessed input string. + if lengths[-1][-1] == 0: + return -1, '' + + # Walk through the table, read the LCS string out from the table and + # finding the requested targetAnchor. This is a bit tricky: + # + # | Interesting case 1: + # | searchText = ab + # | searchAnchor: between ``a`` and ``b``. + # | targetText = a--b + # | There's no clear correct answer for the returned cursor anchor. The most + # natural answer would be between ``a-`` and ``-b``. Therefore, we want to + # interpolate between the two target cursor anchors in this case. + # + # | Interesting case 2a: + # | searchText = Chapter 1:Once upon a time + # | targetText = :---------Once upon a time + # | searchAnchor: between ``Chapter 1:`` and ``Once upon a time``. + # | The LCS in this case is ``:Once upon a time``. There are two mechanically + # value answers: an anchor to the right of the colon, or to the left of the + # O. We obviously want the anchor to the left of the O. + # + # | Interesting case 2b: + # | searchText = Once upon a time, there lived + # | targetText = Once upon a time------------, + # | searchAnchor: between ``Once upon a time`` and ``, there lived``. + # | The LCS in this case is ``Once upon a time,``. There are two mechanically + # valid answers: an anchor to the right of the e, or to the left of the + # comma. We obviously want the anchor to the right of the e. + # + # So, in these cases, prefer the side with the longest consectuive match. + # Given the type of text we're matching (some ignorable markup mixed with + # valid text), picking the valid text instead of interpolating seems best. + # + # Therefore, we need to find the targetText index of both sides of the match + # (unless the match occurs at the beginning or end of the string). If both + # sides of the match refer to the same anchor (i.e. rightIndex == leftIndex + 1), + # then return the anchor between these to characters. Otherwise, return an + # anchor based on which side has more characters in their portion of the lcs + # string. + # + # | Interesting case 3: + # | searchText = a--b + # | targetText = ab + # | searchAnchor: between ``a-`` and ``-b``. + # | The characters near searchAnchor don't appear in targetText. So, pick the + # nearest targetText matches (a and b). + # + # So, walk backwards through the table. + # + # For debug, compute the lcs string. + lcsString = '' + # | x gives the searchText table index; + # | y gives the targetText table index. + # | Start at the end of the table. + x, y = len(searchText), len(targetText) + # Save the table index of the last matching character found. + lastMatchIndex = y + 1 + # Record the length of the lcs match. + lcsLen = 0 + # No anchor placement ambiguioty yet exists. + matchIndices = None + while x != 0 and y != 0: + if lengths[x][y] == lengths[x - 1][y]: + x -= 1 + elif lengths[x][y] == lengths[x][y - 1]: + y -= 1 + else: + assert searchText[x - 1] == targetText[y - 1] + # For debug purposes, uncomment the line below. + ##print('x = %d, y = %d, searchText[x - 1] = %s, targetText[y - 1] = %s' % (x, y, searchText[x - 1], targetText[y - 1])) + + # On a match at or after the anchor (case 3 above -- the anchor + # may lie between non-matching characters)... + if x <= searchAnchor: + # ...we now have a matched character index to the left of the + # anchor. lastMatchIndex holds the matched character index to + # the right of the anchor. + # + # In the simple case either: + # + # * These refer to adjacent characters, making the resulting + # anchor position unambiguous: place it between these two + # characters (to the left of lastMatchIndex == to the right of + # y). + # * Or, this refers to the last matching character in the + # targetText (implying lastMatchIndex == y + 1), again making + # anchor placement unambiguous: to the right of y. + # + # In either case, return y, which refers to the desired anchor. + if y == lastMatchIndex - 1: + return y, lcsString + # Otherwise, we're have to distinguish between case 2a and 2b + # above by comparing the lcs length before after this point. + else: + # Save right LCS length and reset length to record left + # LCS length. + rightLcsLen = lcsLen + 1 + lcsLen = -1 + # Store these two indices, for use when the right LCS length + # is known and a decision about which to use can be made. + matchIndices = (y, lastMatchIndex - 1) + # Keep the if x <= searchAnchor from being true, so that the + # LCS algorithm will run to completion to compute the right + # LCS length. + searchAnchor = -1 + + # Keep track of the last matched character's table index. + lastMatchIndex = y + + # Don't compute the LCS string unless it's actually needed. + if returnLcsString: + lcsString = searchText[x - 1] + lcsString + lcsLen += 1 + x -= 1 + y -= 1 + + # Resolve an ambiguius anchor if necessary. + if matchIndices: + l, r = matchIndices + # lcsLen holds the left LCS length. + if lcsLen >= rightLcsLen: + return l, lcsString + else: + return r, lcsString + + # At this point, we traced the LCS to the beginning of either the + # searchText or the targetText, but haven't moved through + # the desired searchAnchor. There are two cases: + # + # 1. x == 0: when searchAnchor == 0 and the index in y gives the + # corresponding targetText index. + # 2. y == 0: the searchAnchor in the searchText lies before the + # corresponding targetText index. Return y == 0 as the best + # possible corresponding index. + # + # Therefore, return y. + # + # Some examples: + # + # * searchText = 'abcd', searchAnchor = 0 (before 'abcd'), + # targetText = '_abc', then y == 1 when x == 0, which lies between + # the characters '_' and 'abc' in the targetText. + # * searchText = '__ab', searchAnchor = 1 (between '_' and '_ab'), + # targetText = 'ab', then x == 1 when y == 0, which is the beginning + # of the targetText. + return y, lcsString diff --git a/dtop_initial_diagram.png b/dtop_initial_diagram.png new file mode 100644 index 0000000..392972e Binary files /dev/null and b/dtop_initial_diagram.png differ diff --git a/dtop_initial_diagram.vsdx b/dtop_initial_diagram.vsdx new file mode 100644 index 0000000..7018e8f Binary files /dev/null and b/dtop_initial_diagram.vsdx differ diff --git a/mdx_math.py b/mdx_math.py new file mode 100644 index 0000000..ea8a19f --- /dev/null +++ b/mdx_math.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +''' +Math extension for Python-Markdown +================================== + +Adds support for displaying math formulas using [MathJax](http://www.mathjax.org/). + +Author: 2015, Dmitry Shachnev . +Source: https://github.com/mitya57/python-markdown-math, +commit db12837790a0e260c87da4edd9dc276361501897 plus some fixes. +''' + +import markdown + + +class MathExtension(markdown.extensions.Extension): + + def __init__(self, *args, **kwargs): + self.config = { + 'enable_dollar_delimiter': [False, 'Enable single-dollar delimiter'], + } + super(MathExtension, self).__init__(*args, **kwargs) + + def extendMarkdown(self, md, md_globals): + def handle_match_inline(m): + node = markdown.util.etree.Element('script') + node.set('type', 'math/tex') + node.text = markdown.util.AtomicString(m.group(3)) + return node + + def handle_match(m): + node = markdown.util.etree.Element('script') + node.set('type', 'math/tex; mode=display') + if '\\begin' in m.group(2): + node.text = markdown.util.AtomicString(m.group(2) + m.group(4) + m.group(5)) + else: + node.text = markdown.util.AtomicString(m.group(3)) + return node + + inlinemathpatterns = ( + markdown.inlinepatterns.Pattern(r'(? os.path.getmtime(sourceFile): + return sourceFile, '', s, QUrl.fromLocalFile(outputFile) + else: + return (sourceFile, 'The file {} is older than the source file {}.' + .format(outputFile, sourceFile), s, QUrl()) + except OSError as e: + return (sourceFile, 'Error checking modification time: {}'.format(str(e)), + s, QUrl()) +# +# +# Threaded members +# ---------------- +# These functions and classes convert their input to HTML. They are executed in +# a separate thread. +def _convertMarkdown(text): + """Convert Markdown to HTML + """ + try: + import markdown + except ImportError: + return 'Markdown preview requires python-markdown package
' \ + 'Install it with your package manager or see ' \ + 'installation instructions' + + import_path = os.path.join(os.path.dirname(os.path.abspath(__file__))) + if import_path not in sys.path: + sys.path.insert(0, import_path) + extensions = ['fenced_code', 'nl2br', 'tables', 'mdx_math'] + + # 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): + """http://achinghead.com/python-markdown-adding-insert-delete.html + Class is placed here, because depends on imported markdown, and markdown import is lazy + """ + DEL_RE = r'(~~)(.*?)~~' + + def extendMarkdown(self, md, md_globals): + # Create the del pattern + delTag = markdown.inlinepatterns.SimpleTagPattern(self.DEL_RE, 'del') + # Insert del pattern into markdown parser + md.inlinePatterns.add('del', delTag, '>not_strong') + + extensions.append(_StrikeThroughExtension()) + + return markdown.markdown(text, extensions) + + +def _convertReST(text): + try: + import docutils.core + import docutils.writers.html4css1 + except ImportError: + return 'Restructured Text preview requires the python-docutils package.
' \ + 'Install it with your package manager or see ' \ + 'this page.', None + + errStream = io.StringIO() + docutilsHtmlWriterPath = os.path.abspath(os.path.dirname( + docutils.writers.html4css1.__file__)) + settingsDict = { + # Make sure to use Unicode everywhere. This name comes from + # ``docutils.core.publish_string`` version 0.12, lines 392 and following. + 'output_encoding': 'unicode', + # While ``unicode`` **should** work for ``input_encoding``, it doesn't if + # there's an ``.. include`` directive, since this encoding gets passed to + # ``docutils.io.FileInput.__init__``, in which line 236 of version 0.12 + # tries to pass the ``unicode`` encoding to ``open``, producing: + # + # .. code:: python3 + # :number-lines: + # + # File "...\python-3.4.4\lib\site-packages\docutils\io.py", line 236, in __init__ + # self.source = open(source_path, mode, **kwargs) + # LookupError: unknown encoding: unicode + # + # So, use UTF-8 and encode the string first. Ugh. + 'input_encoding' : 'utf-8', + # Don't stop processing, no matter what. + 'halt_level' : 5, + # Capture errors to a string and return it. + 'warning_stream' : errStream, + # On some Windows PC, docutils will complain that it can't find its + # template or stylesheet. On other Windows PCs with the same setup, it + # works fine. ??? So, specify a path to both here. + 'template': ( + os.path.join(docutilsHtmlWriterPath, + docutils.writers.html4css1.Writer.default_template) ), + 'stylesheet_dirs' : ( + docutilsHtmlWriterPath, + os.path.join(os.path.abspath(os.path.dirname( + os.path.realpath(__file__))), 'rst_templates')), + 'stylesheet_path' : 'default.css', + } + htmlString = docutils.core.publish_string(bytes(text, encoding='utf-8'), + writer_name='html', settings_overrides=settingsDict) + errString = errStream.getvalue() + errStream.close() + return htmlString, errString + + +def _convertCodeChat(text, filePath): + # Use StringIO to pass CodeChat compilation information back to + # the UI. + errStream = io.StringIO() + try: + htmlString = CodeToRest.code_to_html_string(text, errStream, + filename=filePath) + except KeyError: + # Although the file extension may be in the list of supported + # extensions, CodeChat may not support the lexer chosen by Pygments. + # For example, a ``.v`` file may be Verilog (supported by CodeChat) + # or Coq (not supported). In this case, provide an error messsage + errStream.write('Error: this file is not supported by CodeChat.') + htmlString = '' + errString = errStream.getvalue() + errStream.close() + return filePath, htmlString, errString, QUrl() + + +class SphinxConverter(QObject): + """This class converts Sphinx input to HTML. It is run in a separate + thread. + """ + # This signal clears the context of the log window. + logWindowClear = pyqtSignal() + + # This signal emits messages for the log window. + logWindowText = pyqtSignal( + # A string to append to the log window. + str) + + def __init__(self, parent): + super().__init__(parent) + # Use an additional thread to process Sphinx output. + self._ac = AsyncController('QThread', self) + self._ac.defaultPriority = QThread.LowPriority + self._SphinxInvocationCount = 1 + + def terminate(self): + # Free resources. + self._ac.terminate() + + def convert(self, filePath): + # Run the builder. + errString = self._runHtmlBuilder() + + # Look for the HTML output. + # + sourcePath = core.config()['Sphinx']['SourcePath'] + outputPath = core.config()['Sphinx']['OutputPath'] + projectPath = core.config()['Sphinx']['ProjectPath'] + # Get an absolute path to the output path and source path, which could be relative. + if not os.path.isabs(sourcePath): + sourcePath = os.path.join(projectPath, sourcePath) + if not os.path.isabs(outputPath): + outputPath = os.path.join(projectPath, outputPath) + # Given ``filePath = sourcePath / path to source file``, we want to compute ``htmlPath = outputPath / path to source file``. + htmlPath = os.path.join(outputPath, os.path.relpath(filePath, sourcePath)) + html_file_suffix = '.html' + try: + with codecs.open(os.path.join(projectPath, 'sphinx-enki-info.txt')) as f: + hfs = f.read() + # If the file is empty, then html_file_suffix wasn't defined + # or is None. In this case, use the default extension. + # Otherwise, use the extension read from the file. + if hfs: + html_file_suffix = hfs + except: + errString = "Warning: assuming .html extension. Use " + \ + "the conf.py template to set the extension.\n" + errString + pass + # First place to look: file.html. For example, look for foo.py + # in foo.py.html. + htmlFile = htmlPath + html_file_suffix + # Second place to look: file without extension.html. For + # example, look for foo.html for foo.rst. + htmlFileAlter = os.path.splitext(htmlPath)[0] + html_file_suffix + # Check that the output file produced by Sphinx is newer than + # the source file it was built from. + if os.path.exists(htmlFile): + return _checkModificationTime(filePath, htmlFile, errString) + elif os.path.exists(htmlFileAlter): + return _checkModificationTime(filePath, htmlFileAlter, errString) + else: + return (filePath, 'No preview for this type of file.
Expected ' + + htmlFile + " or " + htmlFileAlter, errString, QUrl()) + + def _runHtmlBuilder(self): + # Build the commond line for Sphinx. + if core.config()['Sphinx']['AdvancedMode']: + htmlBuilderCommandLine = core.config()['Sphinx']['Cmdline'] + if sys.platform.startswith('linux') or sys.platform.startswith('darwin'): + # If Linux is used, then subprocess cannot take the whole + # commandline as the name of an executable file. Module shlex + # has to be used to parse commandline. + htmlBuilderCommandLine = shlex.split(htmlBuilderCommandLine) + else: + # For available builder options, refer to: http://sphinx-doc.org/builders.html + htmlBuilderCommandLine = [core.config()['Sphinx']['Executable'], + # Place doctrees in the ``_build`` directory; by default, Sphinx + # places this in _build/html/.doctrees. + '-d', os.path.join('_build', 'doctrees'), + # Source directory -- the current directory, since we'll chdir to + # the project directory before executing this. + core.config()['Sphinx']['SourcePath'], + # Build directory + core.config()['Sphinx']['OutputPath']] + + # Invoke it. + try: + # Clear the log at the beginning of a Sphinx build. + self.logWindowClear.emit() + + cwd = core.config()['Sphinx']['ProjectPath'] + # If the command line is already a string (advanced mode), just print it. + # Otherwise, it's a list that should be transformed to a string. + if isinstance(htmlBuilderCommandLine, str): + htmlBuilderCommandLineStr = htmlBuilderCommandLine + else: + htmlBuilderCommandLineStr = ' '.join(htmlBuilderCommandLine) + self.logWindowText.emit('{} : {}\n\n'.format(cwd, + htmlBuilderCommandLineStr)) + + # Sphinx will output just a carriage return (0x0D) to simulate a + # single line being updated by build status and the build + # progresses. Without universal newline support here, we'll wait + # until the build is complete (with a \n\r) to report any build + # progress! So, enable universal newlines, so that each \r will be + # treated as a separate line, providing immediate feedback on build + # progress. + popen = open_console_output(htmlBuilderCommandLine, cwd=cwd, + universal_newlines=True) + # Read are blocking; we can't read from both stdout and stderr in the + # same thread without possible buffer overflows. So, use this thread to + # read from and immediately report progress from stdout. In another + # thread, read all stderr and report that after the build finishes. + q = Queue() + self._ac.start(None, self._stderr_read, popen.stderr, q) + self._popen_read(popen.stdout) + # Wait until stderr has completed (stdout is already done). + stderr_out = q.get() + except OSError as ex: + return ( + 'Failed to execute HTML builder:\n' + '{}\n'.format(str(ex)) + + 'Go to Settings -> Settings -> Sphinx to set HTML' + ' builder configurations.') + + return stderr_out + + # Read from stdout (in this thread) and stderr (in another thread), + # so that the user sees output as the build progresses, rather than only + # producing output after the build is complete. + def _popen_read(self, stdout): + # Read a line of stdout then report it to the user immediately. + s = stdout.readline() + while s: + self.logWindowText.emit(s.rstrip('\n')) + s = stdout.readline() + self._SphinxInvocationCount += 1 + # I would expect the following code to do the same thing. It doesn't: + # instead, it waits until Sphinx completes before returning anything. + # ??? + # + # .. code-block: python + # :linenos: + # + # for s in stdout: + # self.logWindowText.emit(s) + + # Runs in a separate thread to read stdout. It then exits the QEventLoop as + # a way to signal that stderr reads have completed. + def _stderr_read(self, stderr, q): + q.put(stderr.read()) +# +# QWebEngineView tweak +# ==================== +# This class opens links in an external browser, instead of in the built-in browser. +class QWebEnginePageExtLink(QWebEnginePage): + def acceptNavigationRequest(self, url, navigationType, isMainFrame): + # Only open a link externally if the user clicked on it. + # + # The following HTML produces navigationType == 0 (link clicked) and + # isMainFrame == False. (This makes no sense to me). So, only open main frame clicks in an external browser. + ## https://img.shields.io/pypi/v/PyInstaller.svg + if (navigationType == QWebEnginePage.NavigationTypeLinkClicked and isMainFrame): + 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) + + # Tell the built-in browser not to handle this. + return False + else: + # Handle this in the built-in browser. + return True +# +# AfterLoaded +# =========== +# Run functions after the web page is loaded. This avoids errors such as ``js: Uncaught ReferenceError: clearHighlight is not defined``, which (I think) occurs when JavaScript is run before the PreviewSync window is able to inject its JavaScript. +class AfterLoaded(QObject): + def __init__(self, + # The QWebEnginePage to watch for loading/load complete. + webEnginePage): + + super().__init__() + self._runList = [] + self._isLoading = False + webEnginePage.loadStarted.connect(self.onLoadStarted) + webEnginePage.loadFinished.connect(self.onLoadFinished) + + def terminate(self): + self.clearAll() + # Ensure that all signals are disconnected, so that waiting callbacks won't be invoked after this class is terminated. + sip.delete(self) + + def onLoadStarted(self): + self._isLoading = True + + def onLoadFinished(self, ok): + self._isLoading = False + self._runAll() + + # Schedule a funtion to be executed after the web page is finished loading. If the web page has already been loaded, it will execute immediately. Use: ``al.afterLoaded(func_name, param1, param2, ..., kwarg1=kwval1, kwarg2=kwval2, ...)`` will invoke ``func_name(param1, param2, ..., kwarg1=kwval1, kwarg2=kwval2, ...)``. Note that the return values of the functions are discarded. + def afterLoaded(self, *args, **kwargs): + self._runList.append([kwargs, *args]) + if not self._isLoading: + self._runAll() + + def _runAll(self): + while self._runList: + kwargs, func, *args = self._runList.pop(0) + func(*args, **kwargs) + + # Unschedule all functions scheduled to run, but not yet run. + def clearAll(self): + self._runList.clear() +# +# Core class +# ========== +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.fromTheme('view-preview', + QIcon(os.path.join(PLUGIN_DIR, + "view-preview.svg"))), + "Alt+W") + + self._widget = self._createWidget() + # Don't need to schedule document processing; a call to show() does. + + self._afterLoaded = AfterLoaded(self._widget.webEngineView.page()) + + self._loadTemplates() + self._widget.cbTemplate.currentIndexChanged.connect( + self._onCurrentTemplateChanged) + + # 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) + + core.workspace().currentDocumentChanged.connect(self._onDocumentChanged) + core.workspace().textChanged.connect(self._onTextChanged) + + # 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) + + core.workspace().modificationChanged.connect( + self._onDocumentModificationChanged) + + 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() + self._typingTimer.setInterval(800) + self._typingTimer.timeout.connect(self._scheduleDocumentProcessing) + + self.previewSync = PreviewSync(self) + + 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 + # `_. 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) + # The logWindowText signal simply appends text to the log window. + self._sphinxConverter.logWindowText.connect(lambda s: + self._widget.teLog.appendPlainText(s)) + + 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) + # The Qt Designer doesn't support a QWebEngineView. Also, we need to + # add a subclass, which also isn't supported. Add it manually. + widget.webEngineView = QWebEngineView(widget) + widget.webView.layout().addWidget(widget.webEngineView) + # Use our custom subclass for the web page; use the web view as its + # parent. + webEnginePage = QWebEnginePageExtLink(widget.webEngineView) + widget.webEngineView.setPage(webEnginePage) + # Fix preview palette. See https://github.com/bjones1/enki/issues/34 + webEngineViewPalette = widget.webEngineView.palette() + webEngineViewPalette.setColor(QPalette.Inactive, QPalette.HighlightedText, + webEngineViewPalette.color(QPalette.Text)) + widget.webEngineView.setPalette(webEngineViewPalette) + + widget.webEngineView.page().titleChanged.connect( + self._updateTitle) + widget.cbEnableJavascript.clicked.connect( + self._onJavaScriptEnabledCheckbox) + widget.webEngineView.installEventFilter(self) + + self.setWidget(widget) + self.setFocusProxy(widget.webEngineView) + + widget.tbSave.clicked.connect(self.onPreviewSave) + # 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) + + 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.previewSync.terminate() + self._sphinxConverter.terminate() + self._runLatest.terminate() + self._afterLoaded.terminate() + sip.delete(self) + + 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.webEngineView + 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 _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 + """ + page = self._widget.webEngineView.page() + self._scrollPos[self._visiblePath] = page.scrollPosition() + + def _restoreScrollPos(self, ok): + """Restore scroll bar position for document + """ + try: + self._widget.webEngineView.page().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 + + page = self._widget.webEngineView.page() + pos = self._scrollPos[self._visiblePath] + # Odd: this works, too. Evidently, the load finishing doesn't mean the + # render is finished. But, the JavaScript below won't be run until the + # render finishes. However, since this might lead to a race condition + # (what if the render finishes before this code runs), avoid this + # shortcut. + ##pos = page.scrollPosition() + # We can't use view.scroll because it doesn't affect the web view's + # scroll bars -- instead, it will move the widget around, which isn't + # helpful. + page.runJavaScript('window.scrollTo({}, {});'.format(pos.x(), pos.y())) + + # 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() + + # We can't rely on ``self.isVisible()`` here: on startup, it returns False even though the widget is visible, probably because the widget hasn't yet been painted. + if core.config()['Preview']['Enabled']: + self._clear() + self._scheduleDocumentProcessing() + + _CUSTOM_TEMPLATE_PATH = '' + + 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', + 'See ' + 'this 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 to 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 QwebEngineView settings and QCheckBox state + """ + self._widget.cbEnableJavascript.setChecked(enabled) + + settings = self._widget.webEngineView.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): + qe = QEventLoop() + + def callback(text): + 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)) + qe.quit() + + # The preview selection is an extra ``div`` inserted by the sync code. + # Remove it before saving the file. + #self.previewSync.clearHighlight() + self._afterLoaded.afterLoaded(self._widget.webEngineView.page().toHtml, callback) + # Wait for the callback to complete. + qe.exec_() + + # 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], QMessageBox.StandardButton)): + 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:
" + + 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.webEngineView.page().loadFinished.connect( + self._restoreScrollPos) + + # Per http://stackoverflow.com/questions/36609489/how-to-prevent-qwebengineview-to-grab-focus-on-sethtml-and-load-calls, + # the QWebEngineView steals the focus on a call to ``setHtml``. Disable + # it to prevent this. Another approach: disable `QWebEngineSettings::FocusOnNavigationEnabled `_, which is enabled by default. However, since this was added in Qt 5.8 (PyQt 5.8 was `released in 15-Feb-2017 `_, it's too early to rely on it. TODO: use this after PyQt 5.9 is released? + self._widget.webEngineView.setEnabled(False) + if baseUrl.isEmpty(): + # Clear the log, then update it with build content. + self._widget.teLog.clear() + self._widget.webEngineView.setHtml(htmlText, + baseUrl=QUrl.fromLocalFile(filePath)) + else: + self._widget.webEngineView.setUrl(baseUrl) + # Let the sync know that the contents have changed. + # Re-enable it after updating the HTML. + self._widget.webEngineView.setEnabled(True) + + # 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:: + # + # :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 + # + # In Sphinx 1.6.1: + # X:\file.rst: WARNING: document isn't included in any toctree + # + # Each error/warning occupies one line. The following `regular + # expression + # `_ + # 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 `_. + # + # Examining this expression one element at a time:: + # + # :1589: (ERROR/3)Unknown interpreted text role "ref". + errPosRe = ':(\d*|None|):? ' + # Find the first occurence of a pair of colons, or just a single colon. + # Between them there can be numbers or "None" or nothing. For example, + # this expression matches the string ":1589:" or string ":None:" or + # string "::" or the string ":". Next:: + # + # :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 ">" and "<" 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("
\n" +
+                                          html.escape(errString) +
+                                          '
') + # 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) diff --git a/preview_sync.py b/preview_sync.py new file mode 100644 index 0000000..635f3cd --- /dev/null +++ b/preview_sync.py @@ -0,0 +1,844 @@ +# .. -*- coding: utf-8 -*- +# +# ********************************************************************** +# preview_sync.py - Synchronize between text and web views of a document +# ********************************************************************** +# With this module, cursor movement and mouse clicks in either view scroll to +# and highlight the corresponding text in the other view. In addition, the text +# is vertically synchronized: the y coordinate at which the last cursor movement +# or mouse click occurred will show the same text in both views. +# +# Imports +# ======= +# Library imports +# --------------- +from enum import Enum +# +# Third-party +# ----------- +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QObject, QThread, \ + QFile, QIODevice, QEventLoop +from PyQt5 import QtGui +from PyQt5.QtWebChannel import QWebChannel +from PyQt5.QtWebEngineWidgets import QWebEngineScript +import sip +# +# Local +# ----- +from enki.core.core import core +from enki.lib.future import RunLatest + +# If regex isn't installed or is too old, this import will fail. In this case, +# disable the sync feature. +try: + from .approx_match import findApproxTextInTarget +except ImportError as e: + findApproxTextInTarget = None +# +# CallbackManager +# =============== +# Callbacks are a painful additon when porting from QWebKit to QWebEngine. Some operations, just as getting the HTML content of the page, return their value via a callback. Effectively, this is somewhat like a threaded (or, more correctly a multi-process) operation: Qt sends a request to the web engine process, which replies with the requested data. In the meantime, other code runs. When the requested data arrives, Qt invokes the callback. (Why didn't they use signals?) This leads to some challenges: +# +# #. The data the callback operates on may no longer be needed or relevant; for example, if a document was closed, a callback expecting to use the HTML text to synchronize with text in the current document will fail. +# #. When terminating a class, there's no way to know if any callbacks are pending. If they're called after the class terminates, failures will occur. Likewise, there's not way to wait until all callbacks have completed. +# +# As a solution, this class provides future-like behavior for callbacks: a CallbackFuture can be skipped (so that the callback it wraps won't be invoked, providing a way to deal with problem 1 above -- don't invoke callbacks that depend on out-of-date state) or waiting for (to avoid termination errors in problem 2 above). The CallbackManager keeps track of all pending callbacks. +# +# Example use: +# +# .. code:: Python +# :number-lines: +# +# def __init__(self): +# self.cm = CallbackManager() +# +# # This demonstrates wrapping a callback in a CallbackFuture. +# def getPlainText(self): +# # The parameter to ``toPlainText`` is a callback. +# self.webEngineView.page().toPlainText(cm.callback(self.havePlainText)) +# +# def havePlainText(self, txt): +# # Do something with txt. +# +# def setNewHtml(self, html): +# # Cancel any pending callbacks -- with new HTML, the plain text isn't correct. +# self.cm.skipAllCallbacks() +# self.webEngineView.page().setHtml(html) +# # Run it again on the new HTML. +# self.getPlainText() +# +# def terminate(self): +# # Cancel then wait for all callbacks to complete before terminating. +# self.cm.skipAllCallbacks() +# self.cm.waitAllCallbacks() +# +# +class CallbackFutureState(Enum): + READY = 0 + WAITING = 1 + COMPLETE = 2 +# +# CallbackFuture +# -------------- +# A CallbackFuture keeps track of its state (if it's waiting for a callback; if the callback should be skipped). +# +# **Important**: This class is NOT thread-safe. Invoke all of its methods from a single thread. +class CallbackFuture: + def __init__(self, + # A CallbackManager instance to register with. + callbackManager, + # _`callbackToWrap`: The callback function to (optionally) invoke, which this future will wrap. + callbackToWrap): + + self.callbackManager = callbackManager + self._callbackToWrap = callbackToWrap + self._state = CallbackFutureState.READY + self._shouldInvokeCallback = True + self._qEventLoop = None + + # Return the callback wrapper for this future and mark it as waiting. + def callback(self): + assert self._state == CallbackFutureState.READY, 'A callback may only be obtained once.' + self._state = CallbackFutureState.WAITING + self.callbackManager.add(self) + return self._callbackWrapper + + # The callback wrapper. Update state, then invoke the wrapped callback. + def _callbackWrapper(self, *args, **kwargs): + assert self._state == CallbackFutureState.WAITING + self._state = CallbackFutureState.COMPLETE + self.callbackManager.remove(self) + # If waiting for the callback, stop! + if self._qEventLoop: + self._qEventLoop.quit() + if self._shouldInvokeCallback: + self._callbackToWrap(*args, **kwargs) + + def skipCallback(self): + assert self._state == CallbackFutureState.WAITING, 'Only callbacks being waited for may be skipped.' + self._shouldInvokeCallback = False + + # Wait until the wrapped callback is completed (invoked or skipped). + def waitForCallback(self): + assert self._state == CallbackFutureState.WAITING, 'Only callbacks being waited for may be skipped.' + # Run events until the callback is invoked. + self._qEventLoop = QEventLoop() + self._qEventLoop.exec_() + self._qEventLoop = None + assert self._state == CallbackFutureState.COMPLETE + +class CallbackManager(set): + def skipAllCallbacks(self): + for item in self: + item.skipCallback() + + def waitForAllCallbacks(self): + while self: + # Note: we can't iterate over items in the set, since removal of an item (such as a completed CallbackFuture) rasies ``RuntimeError: Set changed size during iteration``. Instead, wait one item in the set. (Don't wait on the rest, since they may no longer belong to the set). The following two lines gets one item from the set. + for item in self: + break + # Wait on it. + item.waitForCallback() + + # A convenience method: wrap a callback and return the wrapper. + def callback(self, + # See callbackToWrap_. + callbackToInvoke): + + return CallbackFuture(self, callbackToInvoke).callback() +# +# PreviewSync +# =========== +class PreviewSync(QObject): + """This class synchronizes the contents of the web and text views and aligns + them vertically. + """ + textToPreviewSynced = pyqtSignal() + + # Setup / cleanup + # =============== + def __init__(self, + # The preview dock involved in synchronization. + previewDock): + + QObject.__init__(self) + # Only set up sync if fuzzy matching is available. + if not findApproxTextInTarget: + return + + # Gather into one variable all the JavaScript needed for PreviewSync. + self._jsPreviewSync = self._jsOnClick + self._jsWebCursorCoords + + self._dock = previewDock + self._callbackManager = CallbackManager() + self._initPreviewToTextSync() + self._initTextToPreviewSync() + self._unitTest = False + + def terminate(self): + # Uninstall the text-to-web sync only if it was installed in the first + # place (it depends on TRE). + if findApproxTextInTarget: + self._cursorMovementTimer.stop() + # Shut down the background sync. If a sync was already in progress, + # then discard its output. + self._runLatest.future.cancel(True) + self._runLatest.terminate() + # End all callbacks. + self._callbackManager.skipAllCallbacks() + self._callbackManager.waitForAllCallbacks() + # Bug: DON'T de-register the QWebChannel. This casues the error message ``onmessage is not a callable property of qt.webChannelTransport. Some things might not work as expected.`` to be displayed. I see this in https://code.woboq.org/qt5/qtwebengine/src/core/renderer/web_channel_ipc_transport.cpp.html#221; I assume it's a result of attempting to send a signal to the web page, where the web page doesn't have qwebchannel.js running. + #self.channel.deregisterObject(self) + # Delete it. Note that the channel is `NOT owned by the page `. + sip.delete(self.channel) + # Disconnect all signals. + sip.delete(self) + # + # Vertical synchronization + ##======================== + # These routines perform vertical synchronization. + # + # This function computes the distance, in pixels, measured from the target + # cursor location to the source cursor location, as shown in part (a) of the + # figure below: delta = source - target, so that source = target + delta. + # This distance is limited by a constraint: the resulting target cursor + # location must be kept a padding pixels amount away from the boundaries of + # the target widget. Part (b) of the figure shows show this distance is + # limited when the source lies above the target widget; the same constraint + # applies when the source lies below the target widget. + # + # .. image:: sync_delta.png + # + # Ideally, this would instead operate on the baseline of the text, rather + # than the bottom, but getting this is harder. + def _alignScrollAmount(self, + # The top (y) coordinate of the source widget in a global coordinate frame, + # such as screen coordinates. In pixels. + sourceGlobalTop, + # The bottom coordinate of the cursor in the source widget, measured from the + # top of the widget, NOT the top of the viewport. In pixels. + sourceCursorBottom, + + # The top (y) coordinate of the target widget in a global coordinate frame, + # such as screen coordinates. In pixels. + targetGlobalTop, + # The bottom coordinate of the cursor in the target widget, measured from the + # top of the widget, NOT the top of the viewport. In pixels. + targetCursorBottom, + # The height of the target widget. In pixels. + targetHeight, + # The height of the cursor in the target widget. In pixels. + targetCursorHeight, + # The minimum allowable distance between target + delta and the top or + # bottom of the target widget. + padding): + + # Compute the raw delta between the source and target widgets. + # + # .. image:: dtop_initial_diagram.png + delta = ( + # Global coords of the source cursor top. + (sourceGlobalTop + sourceCursorBottom) - + # Global coords of the target cursor top. The difference + # gives the number of pixels separating them. + (targetGlobalTop + targetCursorBottom) ); + + # Constrain the resulting delta so that the stays padding pixels from + # the top of the target widget. + delta = max(-targetCursorBottom + targetCursorHeight + padding, delta) + # Likewise, constrain the bottom. + delta = min(targetHeight - targetCursorBottom - padding, delta) + + return delta + + # This string contains JavaScript code to determine the coordinates and height of the + # anchor of the selection in the web view. + _jsWebCursorCoords = ( + # This function returns the [top, left] position in pixels of ``obj`` + # relative to the screen, not to the viewport. This introduces one + # potential problem: if obj is not visible when this is called, it + # returns coordinates outside the screen (such that top or left is + # negative or greater than the screen's height or width. + # + # It was slightly modified from http://www.quirksmode.org/js/findpos.html, + # which reproduces jQuery's offset method (https://api.jquery.com/offset/). + 'function findPos(obj) {' + 'var curLeft = 0;' + 'var curTop = 0;' + # element.offsetLeft and element.offsetTop measure relative to + # the object's parent. Walk the tree of parents, summing each + # offset to determine the offset from the origin of the web page. + 'do {' + 'curLeft += obj.offsetLeft;' + 'curTop += obj.offsetTop;' + '} while (obj = obj.offsetParent);' + # See `element.getBoundingClientRect + # `_ + # for converting viewport coords to screen coords. + 'return [curLeft - window.scrollX, curTop - window.scrollY];' + '}' + + + # This function returns [top, left, width], of the current + # selection, where: + # + # top, left - coordinates of the anchor of the + # selection relative to the screen, in pixels. + # + # height - height at the beginning of the selection, in pixels. + # + # Adapted from http://stackoverflow.com/questions/2031518/javascript-selection-range-coordinates. + # Changes: + # + # - jQuery usage eliminated for all but debug prints. + # - The original code used ``range.endOffset`` instead of + # ``selection.focusOffset``. This caused occasional errors when + # dragging selections. + 'function selectionAnchorCoords() {' + # Using ``window.getSelection()`` + # Make sure a `selection `_ exists. + 'var selection = window.getSelection();' + 'if (selection.rangeCount == 0) return 0;' + + # The selection can contain not just a point (from a + # single mouse click) but a range (from a mouse drag or + # shift+arrow keys). + # We're looking for the coordinates of the focus node + # (the place where the mouse ends up after making the selection). + # However, the range returned by ``selection.getRangeAt(0)`` + # begins earlier in the document and ends later, regardless + # how the mouse was dragged. So, create a new range containing + # just the point at the focus node, so we actually get + # a range pointing to where the mouse is. + # Ref: `focus `_ of the selection. + # `Range `_ + 'var rangeAtFocus = document.createRange();' + 'rangeAtFocus.setStart(selection.focusNode, selection.focusOffset);' + + # Insert a measurable element (a span) at the selection's + # focus. + 'var span = document.createElement("span");' + 'rangeAtFocus.insertNode(span);' + + # Measure coordinates at this span, then remove it. + 'var [left, top] = findPos(span);' + 'var height = span.offsetHeight;' + 'span.remove();' + + 'return [left, top, height];' + '}' + + # Clear the current selection, if it exists. + 'function clearSelection() {' + 'if (window.getSelection()) {' + 'window.getSelection().empty();' + '}' + '}' + + # Given text to find, place a highlight on the last line containing the + # text. + 'function highlightFind(' + # The text to find, typically consisting of all text in the web page + # from its beginning to the point to be found. + 'txt) {' + + # Clear the current selection, so that a find will start at the + # beginning of the page. + 'clearSelection();' + # Find or create a ``div`` used as a highlighter. + 'var highlighter = getHighlight();' + 'if (!highlighter) {' + 'highlighter = document.createElement("div");' + 'document.body.appendChild(highlighter);' + 'highlighter.style.zIndex = 100;' + 'highlighter.style.width = "100%";' + 'highlighter.style.position = "absolute";' + # Pass any click on the highlight on to the webpage underneath. + # See https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events. + 'highlighter.style.pointerEvents = "none";' + 'highlighter.style.backgroundColor = "rgba(255, 255, 0, 0.4)";' + 'highlighter.id = "highlighter";' + '}' + # See https://developer.mozilla.org/en-US/docs/Web/API/Window/find. + ## aString, aCaseSensitive, aBackwards, aWrapAround, aWholeWord, aSearchInFrames, aShowDialog) + 'var found = window.find(txt, true, false, false, false, true, false);' + # If the text was found, or the search string was empty, highlight a line. + 'if (found || txt === "") {' + # Determine the coordiantes of the end of the selection. + 'var res = selectionAnchorCoords();' + 'if (res) {' + # Unpack the coordinates obtained. + 'var [left, top, height] = res;' + # Position it based on the coordinates. + 'highlighter.style.height = height + "px";' + 'highlighter.style.top = (window.scrollY + top) + "px";' + '}' + 'return true;' + '}' + # Remove the highlight if we can't find the text. + 'clearHighlight();' + # Clear the selection, since we won't use it later. + 'clearSelection();' + 'return false;' + '}' + + # Return the ``div`` used to produce a highlight, or None if it doesn't exist. + 'function getHighlight() {' + 'return document.getElementById("highlighter");' + '}' + + # Delete the element used to produce a highlight. + 'function clearHighlight() {' + 'var highlighter = getHighlight();' + 'if (highlighter) {' + 'highlighter.remove();' + '}' + '}') + + # Scroll the web view to align its cursor with the qutepart cursor or vice + # versa. + def _scrollSync(self, + # None to scroll the text view to the y coordinate of the web view's + # cursor. True or False to do the opposite: scroll the web view so that + # its cursor aligns vertically with the y coordinate of the text view. In + # this case, True will use the tolerance to scroll only if the amount to + # scroll exceeds that tolerance; False will scroll irregardless of the + # tolerance. + alreadyScrolling=None, + # Ignored if ``alreadyScrolling == None``. Used as both a padding value and a + # scroll tolerance, as described in alreadyScrolling. + tolerance=50): + + # Per the `window geometry + # `_, + # `geometry() `_ + # is relative to the parent frame. Then, use `mapToGlobal + # `_ to + # put this in global coordinates. This works for `QWebEngineView + # `_, since it + # inherits from QWidget. + wv = self._dock._widget.webEngineView + qp = core.workspace().currentDocument().qutepart + qpGlobalTop = qp.mapToGlobal(qp.geometry().topLeft()).y() + wvGlobalTop = wv.mapToGlobal(wv.geometry().topLeft()).y() + + # `qutepart.cursorRect() + # `_ + # gives a value in viewport == widget coordinates. Use that directly. + cr = qp.cursorRect() + qpCursorHeight = cr.height() + qpCursorBottom = cr.top() + qpCursorHeight + + # Widget height includes the scrollbars. Subtract that off to get a + # viewable height for qutepart. + qpHeight = qp.geometry().height() + hsb = qp.horizontalScrollBar() + # The scrollbar height is a constant, even if it's hidden. So, only + # include it in calculations if it's visible. + if hsb.isVisible(): + qpHeight -= qp.horizontalScrollBar().height() + page = wv.page() + wvHeight = wv.geometry().height() + + # JavaScript callback to determine the coordinates and height of the + # anchor of the selection in the web view. It expects a 3-element tuple + # of (left, top, height), or None if there was no selection, where: + # top is the coordinate (in pixels) of the top of the selection, measured from the web page's origin; + # left is the coordinate (in pixels) of the left of the selection, measured from the web page's origin. + def callback(res): + # See if a 3-element tuple is returned. Exit if the selection was empty. + if not res: + return + + _, wvCursorTop, wvCursorHeight = res + wvCursorBottom = wvCursorTop + wvCursorHeight + + if alreadyScrolling is not None: + deltaY = self._alignScrollAmount(qpGlobalTop, qpCursorBottom, + wvGlobalTop, wvCursorBottom, wvHeight, wvCursorHeight, tolerance) + # Uncomment for helpful debug info. + ##print(("qpGlobalTop = %d, qpCursorBottom = %d, qpHeight = %d, deltaY = %d, tol = %d\n" + + ## " wvGlobalTop = %d, wvCursorBottom = %d, wvHeight = %d, wvCursorHeight = %d") % + ## (qpGlobalTop, qpCursorBottom, qpHeight, deltaY, tolerance, + ## wvGlobalTop, wvCursorBottom, wvHeight, wvCursorHeight)) + + # Only scroll if we've outside the tolerance. + if alreadyScrolling or (abs(deltaY) > tolerance): + # Note that scroll bars are backwards: to make the text go up, you must + # move the bars down (a positive delta) and vice versa. Hence, the + # subtration, rather than addition, below. + page.runJavaScript('window.scrollTo(0, window.scrollY - {});'.format(deltaY)) + # Clear the selection, whether we scrolled or not. + self.clearSelection() + else: + deltaY = self._alignScrollAmount(wvGlobalTop, wvCursorBottom, + qpGlobalTop, qpCursorBottom, qpHeight, qpCursorHeight, 0) + vsb = qp.verticalScrollBar() + # The units for the vertical scroll bar is pixels, not lines. So, do + # a kludgy conversion by assuming that all line heights are the + # same. + vsb.setValue(vsb.value() - round(deltaY/qpCursorHeight)) + + self._dock._afterLoaded.afterLoaded(lambda: page.runJavaScript('selectionAnchorCoords();', QWebEngineScript.ApplicationWorld, self._callbackManager.callback(callback))) + + # Clear the current selection in the web view. + def clearSelection(self): + if not self._unitTest: + self._dock._afterLoaded.afterLoaded(self._dock._widget.webEngineView.page().runJavaScript, 'clearSelection();', QWebEngineScript.ApplicationWorld) + # + # + # Synchronizing between the text pane and the preview pane + ##======================================================== + # A single click in the preview pane should move the text pane's cursor to the + # corresponding location. Likewise, movement of the text pane's cursor should + # select the corresponding text in the preview pane. To do so, an approximate + # search for text surrounding the current cursor or click location perfomed on + # text in the other pane provides the corresponding location in the other pane + # to highlight. + # + # Bugs / to-do items + ##------------------ + # #. I call ``toPlainText()`` several times. In the past, this was quite slow + # in a ``QTextEdit``. Check performance and possibly cache this value; it + # should be easy to update by adding a few lines to _setHtml(). + # + # Preview-to-text sync + ##-------------------- + # This functionaliy relies heavily on the Web to Qt bridge. Some helpful + # references: + # + # * `The QtWebKit Bridge `_ + # gives a helpful overview. + # * `QWebEngineView`_ is the top-level widget used to embed a Web page in a Qt + # application. + # + # For this sync, the first step is to find the single click's location in a + # plain text rendering of the preview's web content. This is implemented in + # JavaScript, which emits a Qt signal with the location on a click. A slot + # connected to this signal then performs the approximate match and updates the + # text pane's cursor. To do this: + # + # #. ``jsClick``, a PyQt signal with a single numeric argument (the index into + # a string containing the plain text rendering of the web page) is defined. + # This signal is `connected `_ to the + # ``onWebviewClick`` slot. + # #. The ``onJavaScriptCleared`` method inserts the JavaScript to listen for a + # click and then emit a signal giving the click's location. + # #. The ``onWebviewClick`` method then performs the approximate match and + # updates the text pane's cursor location. + # #. When a new web page is loaded, all JavaScript is lost and must be reinserted. + # The ``onJavaScriptCleared`` slot, connected to the + # ``javaScriptWindowObjectCleared`` signal, does this. + # + # The job of this JavaScript handler is to + # translate a mouse click into an index into the text rendering of the + # webpage. To do this, we must: + # + # #. Get the current selection made by the mouse click, which is typically + # an empty range. (I assume a click and drag will produce a non-empty + # range; however this code still works). + # #. Extend a copy of this range so that it begins at the start of the + # webpage and, of course, ends at the character nearest the mouse + # click. + # #. Get a string rendering of this range. + # #. Emit a signal with the length of this string. + # + # Note: A JavaScript development environment with this code is available + # at http://jsfiddle.net/hgDwx/110/. + _jsOnClick = ( + + # The `window.onclick + # `_ + # event is "called when the user clicks the mouse button while the + # cursor is in the window." Although the docs claim that "this event + # is fired for any mouse button pressed", I found experimentally + # that it on fires on a left-click release; middle and right clicks + # had no effect. + 'function window_onclick() {' + + # Clear the current highlight -- it doesn't make sense to have other + # text highlighted after a click. + 'clearHighlight();' + + # This performs step 1 above. In particular: + # + # - `window.getSelection `_ + # "returns a `Selection + # `_ + # object representing the range of text selected by the + # user." Since this is only called after a click, I assume + # the Selection object is non-null. + # - The Selection.\ `getRangeAt `_ + # method "returns a range object representing one of the + # ranges currently selected." Per the Selection `glossary + # `_, + # "A user will normally only select a single range at a + # time..." The index for retrieving a single-selection range + # is of course 0. + # - "The `Range `_ + # interface represents a fragment of a document that can + # contain nodes and parts of text nodes in a given document." + # We clone it to avoid modifying the user's existing + # selection using `cloneRange + # `_. + 'var r = window.getSelection().getRangeAt(0).cloneRange();' + + # This performs step 2 above: the cloned range is now changed + # to contain the web page from its beginning to the point where + # the user clicked by calling `setStartBefore + # `_ + # on `document.body + # `_. + 'r.setStartBefore(document.body);' + + # Step 3: + # + # - `cloneContents `_ + # "Returns a `DocumentFragment + # `_ + # copying the nodes of a Range." + # - DocumentFragment's parent `Node `_ + # provides a `textContent + # `_ + # property which gives "a DOMString representing the textual + # content of an element and all its descendants." This therefore + # contains a text rendering of the webpage from the beginning of the + # page to the point where the user clicked. + 'var rStr = r.cloneContents().textContent.toString();' + + + # Step 4: the length of the string gives the index of the click + # into a string containing a text rendering of the webpage. + # Call Python with the document's text and that index. + 'window.previewSync._onWebviewClick(document.body.textContent.toString(), rStr.length);' + '}') + + _qtJsInit = ( + # _`Bug 1`: I can't seem to avoid the error message ``js: Uncaught TypeError: channel.execCallbacks[message.id] is not a function``. It seems like this occurs when a new page is loaded, but the QWebChannel on the Python side sends a message intended for a previously-loaded page. Adding delays / waiting until all JS init finishes / wiating until the event queue is empty helps, but doesn't fix it. Even with these, enough busyness (constant CPU use), this still happends -- perhaps the Python/Qt network backend doesn't send these messages until the CPU is idle? As a workaround, don't define the channel until is needed, making it less likely this will happen. + # + # _`Bug 2`: Since ``qt`` may not be defined (Qt 5.7.0 doesn't provide the + # ``qt`` object to JavaScript when loading per https://bugreports.qt.io/browse/QTBUG-53411), + # wrap it in a try/except block. + 'function init_qwebchannel() {' + # Switch event listeners, part 1/2 -- now that this init is done, don't call it again. + 'window.removeEventListener("click", init_qwebchannel);' + 'try {' + 'new QWebChannel(qt.webChannelTransport, function(channel) {' + # Save a reference to the previewSync object. + 'window.previewSync = channel.objects.previewSync;' + # Switch event listeners, part 2/2 -- Invoke the usual onclick handler. This will only be run if the QWebChannel init succeeds. + 'window.addEventListener("click", window_onclick);' + # Now that the QWebChannel is ready, use it to handle the click. + 'window_onclick();' + '});' + '} catch (err) {' + # Re-throw unrecognized errors. When ``qt`` isn't defined, + # JavaScript reports ``js: Uncaught ReferenceError: qt is not + # defined``; this works around `bug 2`_. + 'throw err;' #if (!(err instanceof ReferenceError)) throw err;' + '}' + '}' + # Set up the sync system after a click. This works around `bug 1`_. See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener. + 'window.addEventListener("click", init_qwebchannel);' + ) + + def _initPreviewToTextSync(self): + """Initialize the system per items 1, 2, and 4 above.""" + # When a web page finishes loading, reinsert our JavaScript. + page = self._dock._widget.webEngineView.page() + + # Insert our scripts into every loaded page. + qwebchannel_js = QFile(':/qtwebchannel/qwebchannel.js') + if not qwebchannel_js.open(QIODevice.ReadOnly): + raise SystemExit( + 'Failed to load qwebchannel.js with error: %s' % + qwebchannel_js.errorString()) + qwebchannel_js = bytes(qwebchannel_js.readAll()).decode('utf-8') + + # Set up the QWebChannel. See http://doc.qt.io/qt-5/qtwebchannel-javascript.html. + # Run the script containing QWebChannel.js first. + beforeScript = QWebEngineScript() + beforeScript.setSourceCode(qwebchannel_js + self._jsPreviewSync + self._qtJsInit) + beforeScript.setName('qwebchannel.js, previewSync') + # Run this JavaScript separated from any JavaScript present in the loaded web page. This provides better security (rogue pages can't access the QWebChannel) and better isolation (handlers, etc. won't conflict, I hope). + beforeScript.setWorldId(QWebEngineScript.ApplicationWorld) + beforeScript.setInjectionPoint(QWebEngineScript.DocumentCreation) + # Per `setWebChannel `_, only one channel is allowed per page. So, don't run this on sub-frames, since it will attempt the creation of more channels for each subframe. + beforeScript.setRunsOnSubFrames(False) + page.scripts().insert(beforeScript) + + # Set up the web channel. See https://riverbankcomputing.com/pipermail/pyqt/2015-August/036346.html + # and http://stackoverflow.com/questions/28565254/how-to-use-qt-webengine-and-qwebchannel. + # For debug, ``set QTWEBENGINE_REMOTE_DEBUGGING=port`` then browse to + # http://127.0.0.1:port, where port=60000 works for me. See https://riverbankcomputing.com/pipermail/pyqt/2015-August/036346.html. + self.channel = QWebChannel(page) + self.channel.registerObject("previewSync", self) + # Expose the ``qt.webChannelTransport`` object in the world where these scripts live. + page.setWebChannel(self.channel, QWebEngineScript.ApplicationWorld) + + @pyqtSlot(str, int) + def _onWebviewClick(self, tc, webIndex): + self._onWebviewClick_(tc, webIndex) + # Get the qutepart text. + qp = core.workspace().currentDocument().qutepart + # Perform an approximate match between the clicked webpage text and the + # qutepart text. + textIndex = findApproxTextInTarget(tc, webIndex, qp.text) + # Move the cursor to textIndex in qutepart, assuming corresponding text + # was found. + if textIndex >= 0: + self._moveTextPaneToIndex(textIndex) + + # Used for testing -- this will be replaced by a mock. Does nothing. + def _onWebviewClick_(self, tc, webIndex): + pass + + def _moveTextPaneToIndex(self, textIndex, noWebSync=True): + """Given an index into the text pane, move the cursor to that index. + + Params: + + - textIndex - The index into the text pane at which to place the cursor. + - noWebSync - True to prevent the web-to-text sync from running as a + result of calling this routine. + """ + # Move the cursor to textIndex. + qp = core.workspace().currentDocument().qutepart + cursor = qp.textCursor() + # Tell the text to preview sync to ignore this cursor position change. + cursor.setPosition(textIndex, QtGui.QTextCursor.MoveAnchor) + self._previewToTextSyncRunning = noWebSync + qp.setTextCursor(cursor) + self._previewToTextSyncRunning = False + # Scroll the document to make sure the cursor is visible. + qp.ensureCursorVisible() + # Sync the cursors. + self._scrollSync() + # Focus on the editor so the cursor will be shown and ready for typing. + core.workspace().focusCurrentDocument() + + # Text-to-preview sync + ##-------------------- + # The opposite direction is easier, since all the work can be done in Python. + # When the cursor moves in the text pane, find its matching location in the + # preview pane using an approximate match. Select several characters before and + # after the matching point to make the location more visible, since the preview + # pane lacks a cursor. Specifically: + # + # #. initTextToPreviewSync sets up a timer and connects the _onCursorPositionChanged method. + # #. _onCursorPositionChanged is called each time the cursor moves. It starts or + # resets a short timer. The timer's expiration calls syncTextToWeb. + # #. syncTextToWeb performs the approximate match, then calls moveWebPaneToIndex + # to sync the web pane with the text pane. + # #. moveWebToPane uses QWebFrame.find to search for the text under the anchor + # then select (or highlight) it. + + def _initTextToPreviewSync(self): + """Called when constructing the PreviewDoc. It performs item 1 above.""" + # Create a timer which will sync the preview with the text cursor a + # short time after cursor movement stops. + self._cursorMovementTimer = QTimer() + self._cursorMovementTimer.setInterval(300) + self._cursorMovementTimer.timeout.connect(self.syncTextToPreview) + # Restart this timer every time the cursor moves. + core.workspace().cursorPositionChanged.connect(self._onCursorPositionChanged) + # Set up a variable to tell us when the preview to text sync just fired, + # disabling this sync. Otherwise, that sync would trigger this sync, + # which is unnecessary. + self._previewToTextSyncRunning = False + # Run the approximate match in a separate thread. Cancel it if the + # document changes. + self._runLatest = RunLatest('QThread', self) + self._runLatest.ac.defaultPriority = QThread.LowPriority + core.workspace().currentDocumentChanged.connect(self._onDocumentChanged) + + def _onDocumentChanged(self, old, new): + self._runLatest.future.cancel(True) + self._callbackManager.skipAllCallbacks() + self._cursorMovementTimer.stop() + + def _onCursorPositionChanged(self): + """Called when the cursor position in the text pane changes. It (re)schedules + a text to web sync per item 2 above. Note that the signal connected to + this slot must be updated when the current document changes, since we only + want cursor movement notification from the active text document. This is + handled in _onDocumentChanged. + """ + # Ignore this callback if a preview to text sync caused it or if the + # preview dock is closed. + if not self._previewToTextSyncRunning and self._dock.isVisible(): + self._cursorMovementTimer.stop() + self._cursorMovementTimer.start() + + def syncTextToPreview(self): + """When the timer above expires, this is called to sync text to preview + per item 3 above. It can also be called when a sync is needed (when + switching windows, for example). + """ + # Only run this if we TRE is installed. + if not findApproxTextInTarget: + return + # Stop the timer; the next cursor movement will restart it. + self._cursorMovementTimer.stop() + # Get a plain text rendering of the web view. Continue execution in a callback. + qp = core.workspace().currentDocument().qutepart + qp_text = qp.text + self._dock._widget.webEngineView.page().toPlainText( + self._callbackManager.callback(self._havePlainText)) + + # Perform an approximate match in a separate thread, then update + # the cursor based on the match results. + def _havePlainText(self, html_text): + # Performance notes: findApproxTextInTarget is REALLY slow. Scrolling + # through preview.py with profiling enabled produced:: + # + # Output from Enki: + # 41130 function calls in 3.642 seconds + # + # Ordered by: standard name + # + # ncalls tottime percall cumtime percall filename:lineno(function) + # 13 0.000 0.000 0.000 0.000 __init__.py:406(text) + # 13 0.000 0.000 3.398 0.261 approx_match.py:138(findApproxText) + # 13 0.000 0.000 3.432 0.264 approx_match.py:175(findApproxTextInTarget) + # 13 0.029 0.002 0.034 0.003 approx_match.py:252(refineSearchResult) + # 26 0.000 0.000 0.000 0.000 core.py:177(workspace) + # ...snip lots more 0.000 or very small times... + # + # Therefore, finding ways to make this faster or run it in another + # thread should significantly improve the GUI's responsiveness. + qp = core.workspace().currentDocument().qutepart + qp_text = qp.text + qp_position = qp.textCursor().position() + self._runLatest.start(self._movePreviewPaneToIndex, + # Call findApproxTextInTarget, returning the index and the HTML text searched. + lambda: (findApproxTextInTarget(qp_text, qp_position, html_text), html_text)) + + def _movePreviewPaneToIndex(self, future): + """Highlights webIndex in the preview pane, per item 4 above. + + Params: + + - webIndex - The index to move the cursor / highlight to in the preview + pane. + - txt - The text of the webpage, returned by mainFrame.toPlainText(). + """ + # Retrieve the return value from findApproxTextInTarget. + webIndex, txt = future.result + + view = self._dock._widget.webEngineView + page = view.page() + ft = txt[:webIndex] + + def callback(found): + if found: + # Sync the cursors. + self._scrollSync(False) + self.textToPreviewSynced.emit() + + if webIndex >= 0: + self._dock._afterLoaded.afterLoaded(lambda: page.runJavaScript('highlightFind({});'.format(repr(ft)), QWebEngineScript.ApplicationWorld, self._callbackManager.callback(callback))) + else: + self.clearHighlight() + + def clearHighlight(self): + self._dock._afterLoaded.afterLoaded(self._dock._widget.webEngineView.page().runJavaScript, 'clearHighlight();', QWebEngineScript.ApplicationWorld) diff --git a/rst_templates/default.css b/rst_templates/default.css new file mode 100644 index 0000000..da3c5bb --- /dev/null +++ b/rst_templates/default.css @@ -0,0 +1,211 @@ +/* + * CSS for PyInstaller Documentation + */ + +body { + font-family: Verdana, Arial, sans-serif; + font-size: 10pt; + background-color: white; +} + +tt, span.literal { + font-family: monospace; + font-size: 0.95em; + background-color: #ECF0F3; + padding: 0 1px; +} + +div.document { + padding-left: 1em; + padding-right: 1em; + /* padding: 0 20px 30px; this overrides the prior 2 lines */ +} +dl, table { + margin-left: 2em; + margin-right: 2em; +} + +h1, h2, h3, h4, h5, h6 { + background-color: #F2F2F2; + border-bottom: 1px solid #CCCCCC; + color: #20435C !important; + font-family: 'Trebuchet MS',sans-serif; + font-weight: normal; + margin: 20px -20px 10px; + padding: 3px 0 3px 10px; +} + +h1 { + text-align: center; + font-variant: small-caps; +} +div#contents p.topic-title.first { + font-size: large; + font-family: 'Trebuchet MS',sans-serif; + font-weight: normal; +} +div.contents { + float:left; + margin-left: -1em; + width: 35%; + background: white; + margin-right: 2em; + padding-right: 0.5em; + border: 1px solid black; + } +a:link, a:visited { color: #006699; } +a:hover { background-color:#006699; color:white; } + +h1 a, h2 a, h3 a, h4 a, h5 a, h6 a, +h1 a:hover, h2 a:hover, h3 a:hover, h4 a:hover, h5 a:hover, h6 a:hover, +h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited { + text-decoration: inherit; + background-color: inherit; + color:inherit; +} +h2 a:hover::after, h3 a:hover::after { + color: red; + content: " back to contents"; + text-decoration: unterline; + font-size: x-small; + margin-left: 1em; +} + +table.docutils { + border: 0 solid #DDCCEE; + border-collapse: collapse; +} +table.docutils td, table.docutils th { + background-color: #EEEEFF; + border-left: 0 none; + padding: 2px 5px; +} +table.docutils td p.last, table.docutils th p.last { + margin-bottom: 0; +} +table.field-list td, table.field-list th { + border: 0 none !important; +} +table.footnote td, table.footnote th { + border: 0 none !important; +} +table.docutils th { + background-color: #EEDDEE; + border-top: 1px solid #CCAACC; +} + +table.docinfo { + border: 0 solid black; + border-collapse: collapse; +} +table.docinfo th { + background-color: #EEDDEE; + border: solid thin #CCAACC; + border-top: 0; + border-left: 0; + padding: 2px 5px; +} +table.docinfo td { + background-color: #EEEEFF; + border: solid thin #EEDDFF; + border-width: 0 0 thin; + padding: 2px 5px; +} + + +table.option-list td.option-group { + padding-left: 0; + background-color: #EEDDEE; +} +table.option-list td.option-group[colspan="2"] { + background-color: #EEEEFF; +} +table.option-list td + td { + padding-bottom: 0.5em; +} +table.option-list kbd { + padding: 3px; + background-color: #EEDDEE; + white-space: nowrap; +} + +th { + padding-right: 5px; + text-align: left; + background: #FFD596; + font-weight: bold; +} +th.head { + text-align: center; +} + +td { + background: white; + text-align: left; +} + + +dl { + margin-bottom: 15px; +} + +dt { + white-space: nowrap; + font-weight: bold; +} + +pre { + background-color: #EEFFCC; + border-color: #AACC99; + border-style: solid none; + border-width: 1px medium; + color: #333333; + line-height: 120%; + padding: 0.5em; + margin-left: 4em; + margin-right: 4em; + font-family: monospace; + font-size: 0.95em; +} + +/*--- sidebar --- */ + +div.sidebar { + margin-left: 1em; + margin-bottom: 1em; + font-size: smaller; + border: medium outset; + padding: 0.5em 1em; /* needed by IE */ + background-color: #ffffee; + width: 35%; + float: right; + clear: right; +} + +p.sidebar-title { + font-weight: bold; + font-size: larger; + color: #006699; + font-variant: small-caps; +} + +div.note { + border: solid thin black; + background-color: #ffffee; + width: 90%; + padding: 0 0.5em; +} + +div.note p.admonition-title { + font-weight: bold; + float: left; + margin-right: 1em; +} + +div.note p.admonition-title:after { + content: ": " +} + +/* + * END OF FILE + */ diff --git a/rst_templates/voidspace.css b/rst_templates/voidspace.css new file mode 100644 index 0000000..050d9f0 --- /dev/null +++ b/rst_templates/voidspace.css @@ -0,0 +1,137 @@ +/* +:Authors: Ian Bicking, Michael Foord +:Contact: fuzzyman@voidspace.org.uk +:Date: 2005/08/26 +:Version: 0.1.0 +:Copyright: This stylesheet has been placed in the public domain. + +Stylesheet for Docutils. +Based on ``blue_box.css`` by Ian Bicking +and ``html4css1.css`` revision 1.46. +*/ + +@import url(html4css1.css); + +body { + font-family: Arial, sans-serif; +} + +em, i { + /* Typically serif fonts have much nicer italics */ + font-family: Times New Roman, Times, serif; +} + +a.target { + color: blue; +} + +a.target { + color: blue; +} + +a.toc-backref { + text-decoration: none; + color: black; +} + +a.toc-backref:hover { + background-color: inherit; +} + +a:hover { + background-color: #cccccc; +} + +div.attention, div.caution, div.danger, div.error, div.hint, +div.important, div.note, div.tip, div.warning { + background-color: #cccccc; + padding: 3px; + width: 80%; +} + +div.admonition p.admonition-title, div.hint p.admonition-title, +div.important p.admonition-title, div.note p.admonition-title, +div.tip p.admonition-title { + text-align: center; + background-color: #999999; + display: block; + margin: 0; +} + +div.attention p.admonition-title, div.caution p.admonition-title, +div.danger p.admonition-title, div.error p.admonition-title, +div.warning p.admonition-title { + color: #cc0000; + font-family: sans-serif; + text-align: center; + background-color: #999999; + display: block; + margin: 0; +} + +h1, h2, h3, h4, h5, h6 { + font-family: Helvetica, Arial, sans-serif; + border: thin solid black; + /* This makes the borders rounded on Mozilla, which pleases me */ + -moz-border-radius: 8px; + padding: 4px; +} + +h1 { + background-color: #444499; + color: #ffffff; + border: medium solid black; +} + +h1 a.toc-backref, h2 a.toc-backref { + color: #ffffff; +} + +h2 { + background-color: #666666; + color: #ffffff; + border: medium solid black; +} + +h3, h4, h5, h6 { + background-color: #cccccc; + color: #000000; +} + +h3 a.toc-backref, h4 a.toc-backref, h5 a.toc-backref, +h6 a.toc-backref { + color: #000000; +} + +h1.title { + text-align: center; + background-color: #444499; + color: #eeeeee; + border: thick solid black; + -moz-border-radius: 20px; +} + +table.footnote { + padding-left: 0.5ex; +} + +table.citation { + padding-left: 0.5ex +} + +pre.literal-block, pre.doctest-block { + border: thin black solid; + padding: 5px; +} + +.image img { border-style : solid; + border-width : 2px; +} + +h1 tt, h2 tt, h3 tt, h4 tt, h5 tt, h6 tt { + font-size: 100%; +} + +code, tt { + color: #000066; +} diff --git a/sphinx_templates/conf.py b/sphinx_templates/conf.py new file mode 100644 index 0000000..b9ef626 --- /dev/null +++ b/sphinx_templates/conf.py @@ -0,0 +1,268 @@ +# .. -*- coding: utf-8 -*- +# +# ******************************************************************* +# template/conf.py - Template configuration file for a Sphinx project +# ******************************************************************* +# +# template documentation build configuration file, created by +# sphinx-quickstart on Wed Sep 03 16:41:42 2014. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'template' +copyright = '2014, Author Name' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.0' +# The full version, including alpha/beta/rc tags. +release = '0.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +html_theme_options = {"nosidebar": "true"} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None +# **Sphinx note:** `Enki `_, which hosts Sphinx, +# needs to know this value. So, save it to a file for Enki_ to read. +import codecs +try: + with codecs.open('sphinx-enki-info.txt', 'wb', 'utf-8') as f: + f.write(html_file_suffix) +except NameError as TypeError: + # If ``html_file_suffix`` isn't defined (NameError) or is None (TypeError), + # Enki will assume ``.html``. + pass +# Output file base name for HTML help builder. +htmlhelp_basename = 'templatedoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + #'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'template.tex', 'template Documentation', + 'Author Name', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'template', 'template Documentation', + ['Author Name'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'template', 'template Documentation', + 'Author Name', 'template', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/sphinx_templates/index.rst b/sphinx_templates/index.rst new file mode 100644 index 0000000..38d1a9f --- /dev/null +++ b/sphinx_templates/index.rst @@ -0,0 +1,21 @@ +.. template documentation master file, created by + sphinx-quickstart on Thu Sep 04 12:36:36 2014. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to template's documentation! +==================================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/sync_delta.png b/sync_delta.png new file mode 100644 index 0000000..687f3c9 Binary files /dev/null and b/sync_delta.png differ diff --git a/sync_delta.vsdx b/sync_delta.vsdx new file mode 100644 index 0000000..ee56545 Binary files /dev/null and b/sync_delta.vsdx differ diff --git a/templates/Default b/templates/Default new file mode 100644 index 0000000..3fde9ba --- /dev/null +++ b/templates/Default @@ -0,0 +1,44 @@ + diff --git a/templates/MathJax b/templates/MathJax new file mode 100644 index 0000000..66b0ac6 --- /dev/null +++ b/templates/MathJax @@ -0,0 +1,47 @@ + + + diff --git a/templates/WhiteOnBlack b/templates/WhiteOnBlack new file mode 100644 index 0000000..03248fe --- /dev/null +++ b/templates/WhiteOnBlack @@ -0,0 +1,43 @@ + diff --git a/view-preview.svg b/view-preview.svg new file mode 100644 index 0000000..6902124 --- /dev/null +++ b/view-preview.svg @@ -0,0 +1 @@ + \ No newline at end of file