コード例 #1
0
    def test_15(self):
        for _ in self.singleThreadOnly:
            rl = RunLatest(_)

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

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

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

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

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

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

            rl.terminate()
コード例 #2
0
ファイル: test_future.py プロジェクト: bjones1/enki
    def test_15(self):
        for _ in self.singleThreadOnly:
            rl = RunLatest(_)

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

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

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

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

            rl.terminate()
コード例 #3
0
    def test_16(self):
        for _ in self.singleThreadOnly:
            rl = RunLatest(_, True)

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

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

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

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

            rl.terminate()
コード例 #4
0
 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)
コード例 #5
0
ファイル: preview_sync.py プロジェクト: gpa14/enki
 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)
コード例 #6
0
ファイル: test_future.py プロジェクト: bjones1/enki
    def test_16(self):
        for _ in self.singleThreadOnly:
            rl = RunLatest(_, True)

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

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

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

            rl.terminate()
コード例 #7
0
ファイル: preview_sync.py プロジェクト: gpa14/enki
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._initPreviewToTextSync()
        self._initTextToPreviewSync()
        if cProfile:
            self._pr = cProfile.Profile()

    def _onJavaScriptCleared(self):
        """This is called before starting a new load of a web page, to inject the
           JavaScript needed for PreviewSync."""
        mf = self._dock._widget.webView.page().mainFrame()
        # Use `addToJavaScriptWindowObject
        # <http://doc.qt.io/qt-4.8/qwebframe.html#addToJavaScriptWindowObject>`_
        # to make this PreviewDock object known to JavaScript, so that
        # JavaScript can emit the ``jsClick`` signal defined by PreviewDock.
        mf.addToJavaScriptWindowObject("PyPreviewDock", self)
        # Use `evaluateJavaScript
        # <http://doc.qt.io/qt-4.8/qwebframe.html#evaluateJavaScript>`_
        # to insert JavaScript needed by PreviewSync.
        res = mf.evaluateJavaScript(self._jsPreviewSync)
        # Make sure no errors were returned; the result should be empty.
        assert not res

    def terminate(self):
        # Uninstall the text-to-web sync only if it was installed in the first
        # place (it depends on TRE).
        if cProfile:
            self._pr.print_stats('cumtime')
        if findApproxTextInTarget:
            self._cursorMovementTimer.stop()
            core.workspace().cursorPositionChanged.disconnect(
                self._onCursorPositionChanged)
            core.workspace().currentDocumentChanged.disconnect(
                self._onDocumentChanged)
            # Shut down the background sync. If a sync was already in progress,
            # then discard its output, since that output might not come until
            # after this routine finishes and this class is not usable. Adding
            # the True guarentees that _movePreviewPaneToIndex will not be
            # invoked after this line.
            self._runLatest.future.cancel(True)
            self._runLatest.terminate()
    #
    # 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
            # <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Selection.focusNode>`_ of the selection.
            # `Range <https://developer.mozilla.org/en-US/docs/Web/API/range>`_
            '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. Note:
            # span.remove() isn't supported in the PyQt 4 I'm running, hence
            # the longer syntax below.
            'ret = findPos(span);'
            'height = span.offsetHeight;'
            'span.parentNode.removeChild(span);'

            ## Return      top,   left, height.
            'return    [ret[0], ret[1], height];'
        '}')

    # Run JavaScript to determine the coordinates and height of the
    # anchor of the selection in the web view.
    #
    # Return values:
    #
    # None if the selection is empty, or (top, left) where:
    #
    #   top - Top of the selection, measured from the web page's origin. In pixels.
    #
    #   left - Left of the selection, measured from the web page's origin. In pixels.
    def _webCursorCoords(self):
        res = self._dock._widget.webView.page().mainFrame(). \
            evaluateJavaScript('selectionAnchorCoords();')
        # See if a 3-element tuple is returned. Null is returned if the
        # selection is empty.
        if not res:
            return None
        left, top, height = res
        return top, height

    # 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 a both a padding value and a
      # scroll tolerance, as described in alreadyScrolling.
      tolerance=0):

        # Per the `window geometry
        # <http://qt-project.org/doc/qt-4.8/application-windows.html#window-geometry>`_,
        # `geometry() <http://qt-project.org/doc/qt-4.8/qwidget.html#geometry-prop>`_
        # is relative to the parent frame. Then, use `mapToGlobal
        # <http://qt-project.org/doc/qt-4.8/qwidget.html#mapToGlobal>`_ to
        # put this in global coordinates. This works for `QWebView
        # <http://doc.qt.io/qt-4.8/qwebview.html>`_, since it
        # inherits from QWidget.
        wv = self._dock._widget.webView
        qp = core.workspace().currentDocument().qutepart
        qpGlobalTop = qp.mapToGlobal(qp.geometry().topLeft()).y()
        wvGlobalTop = wv.mapToGlobal(wv.geometry().topLeft()).y()

        # `qutepart.cursorRect()
        # <http://qt-project.org/doc/qt-4.8/qplaintextedit.html#cursorRect-2>`_
        # 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()
        mf = wv.page().mainFrame()
        # Since `scrollBarGeometry <http://doc.qt.io/qt-4.8/qwebframe.html#scrollBarGeometry>`_
        # returns an empty rect if the scroll bar doesn't exist, just subtract
        # its height.
        wvHeight = wv.geometry().height() - mf.scrollBarGeometry(Qt.Horizontal).height()

        # Use JavaScript to determine web view cursor height top and height.
        # There's no nice Qt way that I'm aware of, since Qt doesn't know about
        # these details inside a web view. If JavaScript can't determine this, then
        # silently abort the sync.
        ret = self._webCursorCoords()
        if not ret:
            return
        wvCursorTop, wvCursorHeight = ret
        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):
                # Scroll based on this info using `setScrollPosition
                # <http://doc.qt.io/qt-4.8/qwebframe.html#scrollPosition-prop>`_.
                #
                # 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.
                mf.setScrollPosition(mf.scrollPosition() - QPoint(0, deltaY))
        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))
    #
    #
    # 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 <http://qt-project.org/doc/qt-4.8/qtwebkit-bridge.html>`_
    #   gives a helpful overview.
    # * `QWebView`_ 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 <onJavaScriptCleared.connect>`_ 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
        # <https://developer.mozilla.org/en-US/docs/Web/API/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.
        'window.onclick = function () {'

             # This performs step 1 above. In particular:
             #
             # - `window.getSelection <https://developer.mozilla.org/en-US/docs/Web/API/Window.getSelection>`_
             #   "returns a `Selection
             #   <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Selection.getRangeAt>`_
             #   method "returns a range object representing one of the
             #   ranges currently selected." Per the Selection `glossary
             #   <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/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
             #   <https://developer.mozilla.org/en-US/docs/Web/API/Range.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
             # <https://developer.mozilla.org/en-US/docs/Web/API/Range.setStartBefore>`_
             # on `document.body
             # <https://developer.mozilla.org/en-US/docs/Web/API/document.body>`_.
            'r.setStartBefore(document.body);'

             # Step 3:
             #
             # - `cloneContents <https://developer.mozilla.org/en-US/docs/Web/API/Range.cloneContents>`_
             #   "Returns a `DocumentFragment
             #   <https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment>`_
             #   copying the nodes of a Range."
             # - DocumentFragment's parent `Node <https://developer.mozilla.org/en-US/docs/Web/API/Node>`_
             #   provides a `textContent
             #   <https://developer.mozilla.org/en-US/docs/Web/API/Node.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.
             # Emit a signal with that information.
            'PyPreviewDock.jsClick(rStr.length);'
        '};')

    # A signal emitted by clicks in the web view, per 1 above.
    jsClick = pyqtSignal(
      # The index of the clicked character in a text rendering
      # of the web page.
      int)

    def _initPreviewToTextSync(self):
        """Initialize the system per items 1, 2, and 4 above."""
        # Insert our on-click JavaScript.
        self._onJavaScriptCleared()
        # .. _onJavaScriptCleared.connect:
        #
        # Connect the signal emitted by the JavaScript onclick handler to
        # ``onWebviewClick``.
        self.jsClick.connect(self._onWebviewClick)
        # Qt emits the `javaScriptWindowObjectCleared
        # <http://doc.qt.io/qt-4.8/qwebframe.html#javaScriptWindowObjectCleared>`_
        # signal when a web page is loaded. When this happens, reinsert our
        # onclick JavaScript.
        self._dock._widget.webView.page().mainFrame(). \
            javaScriptWindowObjectCleared.connect(self._onJavaScriptCleared)

    def _webTextContent(self):
        """Return the ``textContent`` of the entire web page. This differs from
        ``mainFrame().toPlainText()``, which uses ``innerText`` and therefore
        produces a slightly differnt result. Since the JavaScript signal's index
        is computed based on textContent, that must be used for all web to text
        sync operations.
        """
        return (self._dock._widget.webView.page().mainFrame().
                evaluateJavaScript('document.body.textContent.toString()'))

    def _onWebviewClick(self, webIndex):
        """Per item 3 above, this is called when the user clicks in the web view. It
        finds the matching location in the text pane then moves the text pane
        cursor.

        Params:
        webIndex - The index of the clicked character in a text rendering
            of the web page.
        """
        # Retrieve the web page text and the qutepart text.
        tc = self._webTextContent()
        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)

    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._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).
        """
        if cProfile:
            self._pr.enable()
            self._startTime = time()
        # 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()
        # Perform an approximate match in a separate thread, then update
        # the cursor based on the match results.
        mf = self._dock._widget.webView.page().mainFrame()
        qp = core.workspace().currentDocument().qutepart
        txt = mf.toPlainText()
        # 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.
        self._runLatest.start(self._movePreviewPaneToIndex,
                              findApproxTextInTarget, qp.text, qp.textCursor().position(), txt)
        if cProfile:
            print(('Time before: ' + str(time() - self._startTime)))

    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().
        """
        if cProfile:
            print(('Time between: ' + str(time() - self._startTime)))
            self._startTime = time()
        # Retrieve the return value from findApproxTextInTarget.
        webIndex = future.result
        # Only move the cursor to webIndex in the preview pane if
        # corresponding text was found.
        if webIndex < 0:
            return

        # Implementation: there's no direct way I know of to move the cursor in
        # a web page. However, the find operation is fairly common. So, simply
        # search from the beginning of the page for a substring of the web
        # page's text rendering from the beginning to webIndex. Then press home
        # followed by shift+end to select the line the cursor is on. (This
        # relies on the page being editable, which is set below).
        pg = self._dock._widget.webView.page()
        mf = pg.mainFrame()
        # The find operations below change the scroll position. Save, then
        # restore it to avoid the window jumping around.
        scrollPos = mf.scrollPosition()
        # Start the search location at the beginning of the document by clearing
        # the previous selection using `findText
        # <http://qt-project.org/doc/qt-4.8/qwebpage.html#findText>`_ with an
        # empty search string.
        pg.findText('')
        # Find the index with findText_.
        txt = pg.mainFrame().toPlainText()
        ft = txt[:webIndex]
        found = pg.findText(ft, QWebPage.FindCaseSensitively)
        mf.setScrollPosition(scrollPos)

        # Before highlighting a line, make sure the text was found. If the
        # search string was empty, it still counts (found is false, but
        # highlighting will still work).
        if found or (webIndex == 0):
            # Select the entire line containing the anchor: make the page
            # temporarily editable, then press home then shift+end using `keyClick
            # <http://qt-project.org/doc/qt-4.8/qtest.html#keyClick>`_. Other ideas
            # on how to do this:
            #
            # #. The same idea, but done in JavaScript. Playing with this produced
            #    a set of failures -- in a ``conteneditable`` area, I couldn't
            #    perform any edits by sending keypresses. The best reference I
            #    found for injecting keypresses was `this jsbin demo
            #    <http://stackoverflow.com/questions/10455626/keydown-simulation-in-chrome-fires-normally-but-not-the-correct-key/12522769#12522769>`_.
            ice = pg.isContentEditable()
            pg.setContentEditable(True)
            # If the find text ends with a newline, findText doesn't include
            # the newline. Manaully move one char forward in this case to get it.
            # This is tested in test_preview.py:test_sync10, test_sync11.
            if ft and ft[-1] == '\n':
                QTest.keyClick(self._dock._widget.webView, Qt.Key_Right, Qt.ShiftModifier)
            QTest.keyClick(self._dock._widget.webView, Qt.Key_Home)
            QTest.keyClick(self._dock._widget.webView, Qt.Key_End, Qt.ShiftModifier)
            pg.setContentEditable(ice)

            # Sync the cursors. If we're already scrolling, take full advantage
            # of it.
            self._scrollSync(mf.scrollPosition().y() != scrollPos.y(), 50)
            self.textToPreviewSynced.emit()
            if cProfile:
                self._pr.disable()
                print(('Time after: ' + str(time() - self._startTime)))
コード例 #8
0
ファイル: preview.py プロジェクト: o2edu/enki
    def __init__(self):
        DockWidget.__init__(self, core.mainWindow(), "Previe&w",
                            QIcon(':/enkiicons/internet.png'), "Alt+W")

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

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

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

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

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

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

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

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

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

        self._visiblePath = None

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

        self.previewSync = PreviewSync(self)  # del_ called

        self._applyJavaScriptEnabled(self._isJavaScriptEnabled())

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

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

        # Save the initial font, then restore it after a ``clear``. Note that
        # ``clear()`` doesn't reset the `currentCharFormat
        # <http://doc.qt.io/qt-4.8/qplaintextedit.html#currentCharFormat>`_. In
        # fact, clicking in red (error/warning) message in the log window
        # changes the current font to red! So, save it here so that it will be
        # restored correctly on a ``_clear_log``.
        self._defaultLogFont = self._widget.teLog.currentCharFormat()
        # The logWindowClear signal clears the log window.
        self._sphinxConverter.logWindowClear.connect(
            self._clear_log)  # disconnected
        # The logWindowText signal simply appends text to the log window.
        self._sphinxConverter.logWindowText.connect(
            lambda s: self._widget.teLog.appendPlainText(s))  # disconnected
コード例 #9
0
ファイル: preview.py プロジェクト: o2edu/enki
class PreviewDock(DockWidget):
    """GUI and implementation
    """
    # Emitted when this window is closed.
    closed = pyqtSignal()

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

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

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

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

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

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

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

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

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

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

        self._visiblePath = None

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

        self.previewSync = PreviewSync(self)  # del_ called

        self._applyJavaScriptEnabled(self._isJavaScriptEnabled())

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

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

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

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

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

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

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

        return widget

    def _quitingApplication(self):
        self._programRunning = False

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            self._clear()

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

    _CUSTOM_TEMPLATE_PATH = '<custom template>'

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

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

        self._restorePreviousTemplate()

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

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

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

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

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

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

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

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

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

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

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

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

        self._applyJavaScriptEnabled(enabled)

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

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

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

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

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

        if self.isHidden():
            return

        self._typingTimer.stop()

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

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

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

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

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

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

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

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

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

        return errors

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

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

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

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

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

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

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

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

    def _setHtmlProgress(self, text, color=None):
        """Set progress label.
        """
        if color:
            style = 'QLabel { background-color: ' + color + '; }'
        else:
            style = style = 'QLabel {}'
        self._widget.prgStatus.setStyleSheet(style)
        self._widget.prgStatus.setText(text)
コード例 #10
0
ファイル: preview_sync.py プロジェクト: gpa14/enki
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._initPreviewToTextSync()
        self._initTextToPreviewSync()
        if cProfile:
            self._pr = cProfile.Profile()

    def _onJavaScriptCleared(self):
        """This is called before starting a new load of a web page, to inject the
           JavaScript needed for PreviewSync."""
        mf = self._dock._widget.webView.page().mainFrame()
        # Use `addToJavaScriptWindowObject
        # <http://doc.qt.io/qt-4.8/qwebframe.html#addToJavaScriptWindowObject>`_
        # to make this PreviewDock object known to JavaScript, so that
        # JavaScript can emit the ``jsClick`` signal defined by PreviewDock.
        mf.addToJavaScriptWindowObject("PyPreviewDock", self)
        # Use `evaluateJavaScript
        # <http://doc.qt.io/qt-4.8/qwebframe.html#evaluateJavaScript>`_
        # to insert JavaScript needed by PreviewSync.
        res = mf.evaluateJavaScript(self._jsPreviewSync)
        # Make sure no errors were returned; the result should be empty.
        assert not res

    def terminate(self):
        # Uninstall the text-to-web sync only if it was installed in the first
        # place (it depends on TRE).
        if cProfile:
            self._pr.print_stats('cumtime')
        if findApproxTextInTarget:
            self._cursorMovementTimer.stop()
            core.workspace().cursorPositionChanged.disconnect(
                self._onCursorPositionChanged)
            core.workspace().currentDocumentChanged.disconnect(
                self._onDocumentChanged)
            # Shut down the background sync. If a sync was already in progress,
            # then discard its output, since that output might not come until
            # after this routine finishes and this class is not usable. Adding
            # the True guarentees that _movePreviewPaneToIndex will not be
            # invoked after this line.
            self._runLatest.future.cancel(True)
            self._runLatest.terminate()

    #
    # 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
        # <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Selection.focusNode>`_ of the selection.
        # `Range <https://developer.mozilla.org/en-US/docs/Web/API/range>`_
        '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. Note:
        # span.remove() isn't supported in the PyQt 4 I'm running, hence
        # the longer syntax below.
        'ret = findPos(span);'
        'height = span.offsetHeight;'
        'span.parentNode.removeChild(span);'

        ## Return      top,   left, height.
        'return    [ret[0], ret[1], height];'
        '}')

    # Run JavaScript to determine the coordinates and height of the
    # anchor of the selection in the web view.
    #
    # Return values:
    #
    # None if the selection is empty, or (top, left) where:
    #
    #   top - Top of the selection, measured from the web page's origin. In pixels.
    #
    #   left - Left of the selection, measured from the web page's origin. In pixels.
    def _webCursorCoords(self):
        res = self._dock._widget.webView.page().mainFrame(). \
            evaluateJavaScript('selectionAnchorCoords();')
        # See if a 3-element tuple is returned. Null is returned if the
        # selection is empty.
        if not res:
            return None
        left, top, height = res
        return top, height

    # 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 a both a padding value and a
        # scroll tolerance, as described in alreadyScrolling.
        tolerance=0):

        # Per the `window geometry
        # <http://qt-project.org/doc/qt-4.8/application-windows.html#window-geometry>`_,
        # `geometry() <http://qt-project.org/doc/qt-4.8/qwidget.html#geometry-prop>`_
        # is relative to the parent frame. Then, use `mapToGlobal
        # <http://qt-project.org/doc/qt-4.8/qwidget.html#mapToGlobal>`_ to
        # put this in global coordinates. This works for `QWebView
        # <http://doc.qt.io/qt-4.8/qwebview.html>`_, since it
        # inherits from QWidget.
        wv = self._dock._widget.webView
        qp = core.workspace().currentDocument().qutepart
        qpGlobalTop = qp.mapToGlobal(qp.geometry().topLeft()).y()
        wvGlobalTop = wv.mapToGlobal(wv.geometry().topLeft()).y()

        # `qutepart.cursorRect()
        # <http://qt-project.org/doc/qt-4.8/qplaintextedit.html#cursorRect-2>`_
        # 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()
        mf = wv.page().mainFrame()
        # Since `scrollBarGeometry <http://doc.qt.io/qt-4.8/qwebframe.html#scrollBarGeometry>`_
        # returns an empty rect if the scroll bar doesn't exist, just subtract
        # its height.
        wvHeight = wv.geometry().height() - mf.scrollBarGeometry(
            Qt.Horizontal).height()

        # Use JavaScript to determine web view cursor height top and height.
        # There's no nice Qt way that I'm aware of, since Qt doesn't know about
        # these details inside a web view. If JavaScript can't determine this, then
        # silently abort the sync.
        ret = self._webCursorCoords()
        if not ret:
            return
        wvCursorTop, wvCursorHeight = ret
        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):
                # Scroll based on this info using `setScrollPosition
                # <http://doc.qt.io/qt-4.8/qwebframe.html#scrollPosition-prop>`_.
                #
                # 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.
                mf.setScrollPosition(mf.scrollPosition() - QPoint(0, deltaY))
        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))

    #
    #
    # 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 <http://qt-project.org/doc/qt-4.8/qtwebkit-bridge.html>`_
    #   gives a helpful overview.
    # * `QWebView`_ 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 <onJavaScriptCleared.connect>`_ 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
        # <https://developer.mozilla.org/en-US/docs/Web/API/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.
        'window.onclick = function () {'

        # This performs step 1 above. In particular:
        #
        # - `window.getSelection <https://developer.mozilla.org/en-US/docs/Web/API/Window.getSelection>`_
        #   "returns a `Selection
        #   <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Selection.getRangeAt>`_
        #   method "returns a range object representing one of the
        #   ranges currently selected." Per the Selection `glossary
        #   <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/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
        #   <https://developer.mozilla.org/en-US/docs/Web/API/Range.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
        # <https://developer.mozilla.org/en-US/docs/Web/API/Range.setStartBefore>`_
        # on `document.body
        # <https://developer.mozilla.org/en-US/docs/Web/API/document.body>`_.
        'r.setStartBefore(document.body);'

        # Step 3:
        #
        # - `cloneContents <https://developer.mozilla.org/en-US/docs/Web/API/Range.cloneContents>`_
        #   "Returns a `DocumentFragment
        #   <https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment>`_
        #   copying the nodes of a Range."
        # - DocumentFragment's parent `Node <https://developer.mozilla.org/en-US/docs/Web/API/Node>`_
        #   provides a `textContent
        #   <https://developer.mozilla.org/en-US/docs/Web/API/Node.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.
        # Emit a signal with that information.
        'PyPreviewDock.jsClick(rStr.length);'
        '};')

    # A signal emitted by clicks in the web view, per 1 above.
    jsClick = pyqtSignal(
        # The index of the clicked character in a text rendering
        # of the web page.
        int)

    def _initPreviewToTextSync(self):
        """Initialize the system per items 1, 2, and 4 above."""
        # Insert our on-click JavaScript.
        self._onJavaScriptCleared()
        # .. _onJavaScriptCleared.connect:
        #
        # Connect the signal emitted by the JavaScript onclick handler to
        # ``onWebviewClick``.
        self.jsClick.connect(self._onWebviewClick)
        # Qt emits the `javaScriptWindowObjectCleared
        # <http://doc.qt.io/qt-4.8/qwebframe.html#javaScriptWindowObjectCleared>`_
        # signal when a web page is loaded. When this happens, reinsert our
        # onclick JavaScript.
        self._dock._widget.webView.page().mainFrame(). \
            javaScriptWindowObjectCleared.connect(self._onJavaScriptCleared)

    def _webTextContent(self):
        """Return the ``textContent`` of the entire web page. This differs from
        ``mainFrame().toPlainText()``, which uses ``innerText`` and therefore
        produces a slightly differnt result. Since the JavaScript signal's index
        is computed based on textContent, that must be used for all web to text
        sync operations.
        """
        return (self._dock._widget.webView.page().mainFrame().
                evaluateJavaScript('document.body.textContent.toString()'))

    def _onWebviewClick(self, webIndex):
        """Per item 3 above, this is called when the user clicks in the web view. It
        finds the matching location in the text pane then moves the text pane
        cursor.

        Params:
        webIndex - The index of the clicked character in a text rendering
            of the web page.
        """
        # Retrieve the web page text and the qutepart text.
        tc = self._webTextContent()
        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)

    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._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).
        """
        if cProfile:
            self._pr.enable()
            self._startTime = time()
        # 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()
        # Perform an approximate match in a separate thread, then update
        # the cursor based on the match results.
        mf = self._dock._widget.webView.page().mainFrame()
        qp = core.workspace().currentDocument().qutepart
        txt = mf.toPlainText()
        # 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.
        self._runLatest.start(self._movePreviewPaneToIndex,
                              findApproxTextInTarget, qp.text,
                              qp.textCursor().position(), txt)
        if cProfile:
            print(('Time before: ' + str(time() - self._startTime)))

    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().
        """
        if cProfile:
            print(('Time between: ' + str(time() - self._startTime)))
            self._startTime = time()
        # Retrieve the return value from findApproxTextInTarget.
        webIndex = future.result
        # Only move the cursor to webIndex in the preview pane if
        # corresponding text was found.
        if webIndex < 0:
            return

        # Implementation: there's no direct way I know of to move the cursor in
        # a web page. However, the find operation is fairly common. So, simply
        # search from the beginning of the page for a substring of the web
        # page's text rendering from the beginning to webIndex. Then press home
        # followed by shift+end to select the line the cursor is on. (This
        # relies on the page being editable, which is set below).
        pg = self._dock._widget.webView.page()
        mf = pg.mainFrame()
        # The find operations below change the scroll position. Save, then
        # restore it to avoid the window jumping around.
        scrollPos = mf.scrollPosition()
        # Start the search location at the beginning of the document by clearing
        # the previous selection using `findText
        # <http://qt-project.org/doc/qt-4.8/qwebpage.html#findText>`_ with an
        # empty search string.
        pg.findText('')
        # Find the index with findText_.
        txt = pg.mainFrame().toPlainText()
        ft = txt[:webIndex]
        found = pg.findText(ft, QWebPage.FindCaseSensitively)
        mf.setScrollPosition(scrollPos)

        # Before highlighting a line, make sure the text was found. If the
        # search string was empty, it still counts (found is false, but
        # highlighting will still work).
        if found or (webIndex == 0):
            # Select the entire line containing the anchor: make the page
            # temporarily editable, then press home then shift+end using `keyClick
            # <http://qt-project.org/doc/qt-4.8/qtest.html#keyClick>`_. Other ideas
            # on how to do this:
            #
            # #. The same idea, but done in JavaScript. Playing with this produced
            #    a set of failures -- in a ``conteneditable`` area, I couldn't
            #    perform any edits by sending keypresses. The best reference I
            #    found for injecting keypresses was `this jsbin demo
            #    <http://stackoverflow.com/questions/10455626/keydown-simulation-in-chrome-fires-normally-but-not-the-correct-key/12522769#12522769>`_.
            ice = pg.isContentEditable()
            pg.setContentEditable(True)
            # If the find text ends with a newline, findText doesn't include
            # the newline. Manaully move one char forward in this case to get it.
            # This is tested in test_preview.py:test_sync10, test_sync11.
            if ft and ft[-1] == '\n':
                QTest.keyClick(self._dock._widget.webView, Qt.Key_Right,
                               Qt.ShiftModifier)
            QTest.keyClick(self._dock._widget.webView, Qt.Key_Home)
            QTest.keyClick(self._dock._widget.webView, Qt.Key_End,
                           Qt.ShiftModifier)
            pg.setContentEditable(ice)

            # Sync the cursors. If we're already scrolling, take full advantage
            # of it.
            self._scrollSync(mf.scrollPosition().y() != scrollPos.y(), 50)
            self.textToPreviewSynced.emit()
            if cProfile:
                self._pr.disable()
                print(('Time after: ' + str(time() - self._startTime)))
コード例 #11
0
ファイル: preview_sync.py プロジェクト: bjones1/enki
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 <http://doc.qt.io/qt-5/qwebenginepage.html#setWebChannel>`.
            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
            # <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Selection.focusNode>`_ of the selection.
            # `Range <https://developer.mozilla.org/en-US/docs/Web/API/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
        # <http://qt-project.org/doc/qt-4.8/application-windows.html#window-geometry>`_,
        # `geometry() <http://qt-project.org/doc/qt-4.8/qwidget.html#geometry-prop>`_
        # is relative to the parent frame. Then, use `mapToGlobal
        # <http://qt-project.org/doc/qt-4.8/qwidget.html#mapToGlobal>`_ to
        # put this in global coordinates. This works for `QWebEngineView
        # <http://doc.qt.io/qt-5/qwebengineview.html>`_, 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()
        # <http://qt-project.org/doc/qt-4.8/qplaintextedit.html#cursorRect-2>`_
        # 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 <http://qt-project.org/doc/qt-4.8/qtwebkit-bridge.html>`_
    #   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 <onJavaScriptCleared.connect>`_ 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
        # <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Window.getSelection>`_
             #   "returns a `Selection
             #   <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Selection.getRangeAt>`_
             #   method "returns a range object representing one of the
             #   ranges currently selected." Per the Selection `glossary
             #   <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/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
             #   <https://developer.mozilla.org/en-US/docs/Web/API/Range.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
             # <https://developer.mozilla.org/en-US/docs/Web/API/Range.setStartBefore>`_
             # on `document.body
             # <https://developer.mozilla.org/en-US/docs/Web/API/document.body>`_.
            'r.setStartBefore(document.body);'

             # Step 3:
             #
             # - `cloneContents <https://developer.mozilla.org/en-US/docs/Web/API/Range.cloneContents>`_
             #   "Returns a `DocumentFragment
             #   <https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment>`_
             #   copying the nodes of a Range."
             # - DocumentFragment's parent `Node <https://developer.mozilla.org/en-US/docs/Web/API/Node>`_
             #   provides a `textContent
             #   <https://developer.mozilla.org/en-US/docs/Web/API/Node.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 <http://doc.qt.io/qt-5/qwebenginepage.html#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)
コード例 #12
0
ファイル: preview.py プロジェクト: rapgro/enki
    def __init__(self):
        DockWidget.__init__(self, core.mainWindow(), "Previe&w", QIcon(':/enkiicons/internet.png'), "Alt+W")

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

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

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

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

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

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

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

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

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

        self._visiblePath = None

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

        self.previewSync = PreviewSync(self)  # del_ called

        self._applyJavaScriptEnabled(self._isJavaScriptEnabled())

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

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

        # Save the initial font, then restore it after a ``clear``. Note that
        # ``clear()`` doesn't reset the `currentCharFormat
        # <http://doc.qt.io/qt-4.8/qplaintextedit.html#currentCharFormat>`_. In
        # fact, clicking in red (error/warning) message in the log window
        # changes the current font to red! So, save it here so that it will be
        # restored correctly on a ``_clear_log``.
        self._defaultLogFont = self._widget.teLog.currentCharFormat()
        # The logWindowClear signal clears the log window.
        self._sphinxConverter.logWindowClear.connect(self._clear_log)  # disconnected
        # The logWindowText signal simply appends text to the log window.
        self._sphinxConverter.logWindowText.connect(lambda s:
                                           self._widget.teLog.appendPlainText(s))  # disconnected
コード例 #13
0
ファイル: preview.py プロジェクト: rapgro/enki
class PreviewDock(DockWidget):
    """GUI and implementation
    """
    # Emitted when this window is closed.
    closed = pyqtSignal()

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

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

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

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

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

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

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

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

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

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

        self._visiblePath = None

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

        self.previewSync = PreviewSync(self)  # del_ called

        self._applyJavaScriptEnabled(self._isJavaScriptEnabled())

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

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

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

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

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

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

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

        return widget

    def _quitingApplication(self):
        self._programRunning = False

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            self._clear()

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

    _CUSTOM_TEMPLATE_PATH = '<custom template>'

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

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

        self._restorePreviousTemplate()

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

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

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

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

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

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

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

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

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

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

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

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

        self._applyJavaScriptEnabled(enabled)

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

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

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

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

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

        if self.isHidden():
            return

        self._typingTimer.stop()

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

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

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

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

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

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

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

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

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

        return errors

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

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

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

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

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

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

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

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

    def _setHtmlProgress(self, text, color=None):
        """Set progress label.
        """
        if color:
            style = 'QLabel { background-color: ' + color + '; }'
        else:
            style = style = 'QLabel {}'
        self._widget.prgStatus.setStyleSheet(style)
        self._widget.prgStatus.setText(text)
コード例 #14
0
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 <http://doc.qt.io/qt-5/qwebenginepage.html#setWebChannel>`.
            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
        # <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Selection.focusNode>`_ of the selection.
        # `Range <https://developer.mozilla.org/en-US/docs/Web/API/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
        # <http://qt-project.org/doc/qt-4.8/application-windows.html#window-geometry>`_,
        # `geometry() <http://qt-project.org/doc/qt-4.8/qwidget.html#geometry-prop>`_
        # is relative to the parent frame. Then, use `mapToGlobal
        # <http://qt-project.org/doc/qt-4.8/qwidget.html#mapToGlobal>`_ to
        # put this in global coordinates. This works for `QWebEngineView
        # <http://doc.qt.io/qt-5/qwebengineview.html>`_, 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()
        # <http://qt-project.org/doc/qt-4.8/qplaintextedit.html#cursorRect-2>`_
        # 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 <http://qt-project.org/doc/qt-4.8/qtwebkit-bridge.html>`_
    #   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 <onJavaScriptCleared.connect>`_ 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
        # <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Window.getSelection>`_
        #   "returns a `Selection
        #   <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/Selection.getRangeAt>`_
        #   method "returns a range object representing one of the
        #   ranges currently selected." Per the Selection `glossary
        #   <https://developer.mozilla.org/en-US/docs/Web/API/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 <https://developer.mozilla.org/en-US/docs/Web/API/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
        #   <https://developer.mozilla.org/en-US/docs/Web/API/Range.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
        # <https://developer.mozilla.org/en-US/docs/Web/API/Range.setStartBefore>`_
        # on `document.body
        # <https://developer.mozilla.org/en-US/docs/Web/API/document.body>`_.
        'r.setStartBefore(document.body);'

        # Step 3:
        #
        # - `cloneContents <https://developer.mozilla.org/en-US/docs/Web/API/Range.cloneContents>`_
        #   "Returns a `DocumentFragment
        #   <https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment>`_
        #   copying the nodes of a Range."
        # - DocumentFragment's parent `Node <https://developer.mozilla.org/en-US/docs/Web/API/Node>`_
        #   provides a `textContent
        #   <https://developer.mozilla.org/en-US/docs/Web/API/Node.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 <http://doc.qt.io/qt-5/qwebenginepage.html#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)