eric7/HelpViewer/HelpViewerImplQWE.py

branch
eric7
changeset 8696
7e88f292b1b1
parent 8694
ee70b17dcd71
child 8702
131ef7267fd4
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/HelpViewer/HelpViewerImplQWE.py	Sun Oct 17 15:26:31 2021 +0200
@@ -0,0 +1,739 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the help viewer base class.
+"""
+
+import functools
+
+from PyQt6.QtCore import pyqtSlot, Qt, QEvent, QTimer, QUrl, QPoint
+from PyQt6.QtGui import QGuiApplication, QClipboard, QContextMenuEvent
+from PyQt6.QtWidgets import QMenu
+from PyQt6.QtWebEngineWidgets import QWebEngineView
+from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineNewWindowRequest
+
+from .HelpViewerWidget import HelpViewerWidget
+from .HelpViewerImpl import HelpViewerImpl
+
+import UI.PixmapCache
+
+
+class HelpViewerImplQWE(HelpViewerImpl, QWebEngineView):
+    """
+    Class implementing the QTextBrowser based help viewer class.
+    """
+    ZoomLevels = [
+        30, 40, 50, 67, 80, 90,
+        100,
+        110, 120, 133, 150, 170, 200, 220, 233, 250, 270, 285, 300,
+    ]
+    ZoomLevelDefault = 100
+    
+    def __init__(self, engine, parent=None):
+        """
+        Constructor
+        
+        @param engine reference to the help engine
+        @type QHelpEngine
+        @param parent reference to the parent widget
+        @type QWidget
+        """
+        QWebEngineView.__init__(self, parent=parent)
+        HelpViewerImpl.__init__(self, engine)
+        
+        self.__helpViewerWidget = parent
+        
+        self.__rwhvqt = None
+        self.installEventFilter(self)
+        
+        self.__page = None
+        self.__createNewPage()
+        
+        self.__currentScale = 100
+        
+        self.__menu = QMenu(self)
+    
+    def __createNewPage(self):
+        """
+        Private method to create a new page object.
+        """
+        self.__page = QWebEnginePage(self.__helpViewerWidget.webProfile())
+        self.setPage(self.__page)
+        
+        self.__page.titleChanged.connect(self.__titleChanged)
+        self.__page.urlChanged.connect(self.__titleChanged)
+        self.__page.newWindowRequested.connect(self.__newWindowRequested)
+    
+    def __newWindowRequested(self, request):
+        """
+        Private slot handling new window requests of the web page.
+        
+        @param request reference to the new window request
+        @type QWebEngineNewWindowRequest
+        """
+        background = (
+            request.destination() ==
+            QWebEngineNewWindowRequest.DestinationType.InNewBackgroundTab
+        )
+        newViewer = self.__helpViewerWidget.addPage(background=background)
+        request.openIn(newViewer.page())
+    
+    def __setRwhvqt(self):
+        """
+        Private slot to set widget that receives input events.
+        """
+        self.grabGesture(Qt.GestureType.PinchGesture)
+        self.__rwhvqt = self.focusProxy()
+        if self.__rwhvqt:
+            self.__rwhvqt.grabGesture(Qt.GestureType.PinchGesture)
+            self.__rwhvqt.installEventFilter(self)
+        else:
+            print("Focus proxy is null!")   # __IGNORE_WARNING_M801__
+    
+    def setLink(self, url):
+        """
+        Public method to set the URL of the document to be shown.
+        
+        @param url URL of the document
+        @type QUrl
+        """
+        super().setUrl(url)
+    
+    def link(self):
+        """
+        Public method to get the URL of the shown document.
+        
+        @return url URL of the document
+        @rtype QUrl
+        """
+        return super().url()
+    
+    @pyqtSlot()
+    def __titleChanged(self):
+        """
+        Private method to handle a change of the web page title.
+        """
+        super().titleChanged.emit()
+    
+    def pageTitle(self):
+        """
+        Public method get the page title.
+        
+        @return page title
+        @rtype str
+        """
+        titleStr = super().title()
+        if not titleStr:
+            if self.link().isEmpty():
+                url = self.__page.requestedUrl()
+            else:
+                url = self.link()
+            
+            titleStr = url.host()
+            if not titleStr:
+                titleStr = url.toString(
+                    QUrl.UrlFormattingOption.RemoveFragment)
+        
+        if not titleStr or titleStr == "about:blank":
+            titleStr = self.tr("Empty Page")
+        
+        return titleStr
+    
+    #######################################################################
+    ## History related methods below
+    #######################################################################
+    
+    def isBackwardAvailable(self):
+        """
+        Public method to check, if stepping backward through the history is
+        available.
+        
+        @return flag indicating backward stepping is available
+        @rtype bool
+        """
+        return self.history().canGoBack()
+    
+    def isForwardAvailable(self):
+        """
+        Public method to check, if stepping forward through the history is
+        available.
+        
+        @return flag indicating forward stepping is available
+        @rtype bool
+        """
+        return self.history().canGoForward()
+    
+    def backward(self):
+        """
+        Public slot to move backwards in history.
+        """
+        self.triggerPageAction(QWebEnginePage.WebAction.Back)
+    
+    def forward(self):
+        """
+        Public slot to move forward in history.
+        """
+        self.triggerPageAction(QWebEnginePage.WebAction.Forward)
+    
+    def reload(self):
+        """
+        Public slot to reload the current page.
+        """
+        self.triggerPageAction(QWebEnginePage.WebAction.Reload)
+    
+    def backwardHistoryCount(self):
+        """
+        Public method to get the number of available back history items.
+        
+        Note: For performance reasons this is limited to the maximum number of
+        history items the help viewer is interested in.
+        
+        @return count of available back history items
+        @rtype int
+        """
+        history = self.history()
+        return len(history.backItems(HelpViewerWidget.MaxHistoryItems))
+    
+    def forwardHistoryCount(self):
+        """
+        Public method to get the number of available forward history items.
+        
+        Note: For performance reasons this is limited to the maximum number of
+        history items the help viewer is interested in.
+        
+        @return count of available forward history items
+        @rtype int
+        """
+        history = self.history()
+        return len(history.forwardItems(HelpViewerWidget.MaxHistoryItems))
+    
+    def historyTitle(self, offset):
+        """
+        Public method to get the title of a history item.
+        
+        @param offset offset of the item with respect to the current page
+        @type int
+        @return title of the requeted item in history
+        @rtype str
+        """
+        history = self.history()
+        currentIndex = history.currentItemIndex()
+        itm = self.history().itemAt(currentIndex + offset)
+        return itm.title()
+    
+    def gotoHistory(self, offset):
+        """
+        Public method to go to a history item.
+        
+        @param offset offset of the item with respect to the current page
+        @type int
+        """
+        history = self.history()
+        currentIndex = history.currentItemIndex()
+        itm = self.history().itemAt(currentIndex + offset)
+        history.goToItem(itm)
+    
+    def clearHistory(self):
+        """
+        Public method to clear the history.
+        """
+        self.history().clear()
+    
+    #######################################################################
+    ## Zoom related methods below
+    #######################################################################
+    
+    def __levelForScale(self, scale):
+        """
+        Private method determining the zoom level index given a zoom factor.
+        
+        @param scale zoom factor
+        @type int
+        @return index of zoom factor
+        @rtype int
+        """
+        try:
+            index = self.ZoomLevels.index(scale)
+        except ValueError:
+            for _index in range(len(self.ZoomLevels)):
+                if scale <= self.ZoomLevels[scale]:
+                    break
+        return index
+    
+    def scaleUp(self):
+        """
+        Public method to zoom in.
+        """
+        index = self.__levelForScale(self.__currentScale)
+        if index < len(self.ZoomLevels) - 1:
+            self.setScale(self.ZoomLevels[index + 1])
+    
+    def scaleDown(self):
+        """
+        Public method to zoom out.
+        """
+        index = self.__levelForScale(self.__currentScale)
+        if index > 0:
+            self.setScale(self.ZoomLevels[index - 1])
+    
+    def setScale(self, scale):
+        """
+        Public method to set the zoom level.
+        
+        @param scale zoom level to set
+        @type int
+        """
+        if scale != self.__currentScale:
+            self.setZoomFactor(scale / 100.0)
+            self.__currentScale = scale
+            self.zoomChanged.emit()
+    
+    def resetScale(self):
+        """
+        Public method to reset the zoom level.
+        """
+        index = self.__levelForScale(self.ZoomLevelDefault)
+        self.setScale(self.ZoomLevels[index])
+    
+    def scale(self):
+        """
+        Public method to get the zoom level.
+        
+        @return current zoom level
+        @rtype int
+        """
+        return self.__currentScale
+    
+    def isScaleUpAvailable(self):
+        """
+        Public method to check, if the max. zoom level is reached.
+        
+        @return flag indicating scale up is available
+        @rtype bool
+        """
+        index = self.__levelForScale(self.__currentScale)
+        return index < len(self.ZoomLevels) - 1
+    
+    def isScaleDownAvailable(self):
+        """
+        Public method to check, if the min. zoom level is reached.
+        
+        @return flag indicating scale down is available
+        @rtype bool
+        """
+        index = self.__levelForScale(self.__currentScale)
+        return index > 0
+    
+    #######################################################################
+    ## Event handlers below
+    #######################################################################
+    
+    def eventFilter(self, obj, evt):
+        """
+        Public method to process event for other objects.
+        
+        @param obj reference to object to process events for
+        @type QObject
+        @param evt reference to event to be processed
+        @type QEvent
+        @return flag indicating that the event should be filtered out
+        @rtype bool
+        """
+        if (
+            obj is self and
+            evt.type() == QEvent.Type.ParentChange and
+            self.parentWidget() is not None
+        ):
+            self.parentWidget().installEventFilter(self)
+        
+        # find the render widget receiving events for the web page
+        if obj is self and evt.type() == QEvent.Type.ChildAdded:
+            QTimer.singleShot(0, self.__setRwhvqt)
+        
+        # forward events to WebBrowserView
+        if (
+            obj is self.__rwhvqt and
+            evt.type() in [QEvent.Type.KeyPress,
+                           QEvent.Type.MouseButtonRelease,
+                           QEvent.Type.Wheel,
+                           QEvent.Type.Gesture]
+        ):
+            wasAccepted = evt.isAccepted()
+            evt.setAccepted(False)
+            if evt.type() == QEvent.Type.KeyPress:
+                self._keyPressEvent(evt)
+            elif evt.type() == QEvent.Type.MouseButtonRelease:
+                self._mouseReleaseEvent(evt)
+            elif evt.type() == QEvent.Type.Wheel:
+                self._wheelEvent(evt)
+            elif evt.type() == QEvent.Type.Gesture:
+                self._gestureEvent(evt)
+            ret = evt.isAccepted()
+            evt.setAccepted(wasAccepted)
+            return ret
+        
+        if (
+            obj is self.parentWidget() and
+            evt.type() in [QEvent.Type.KeyPress, QEvent.Type.KeyRelease]
+        ):
+            wasAccepted = evt.isAccepted()
+            evt.setAccepted(False)
+            if evt.type() == QEvent.Type.KeyPress:
+                self._keyPressEvent(evt)
+            ret = evt.isAccepted()
+            evt.setAccepted(wasAccepted)
+            return ret
+        
+        # block already handled events
+        if (
+            obj is self and
+            evt.type() in [QEvent.Type.KeyPress,
+                           QEvent.Type.MouseButtonRelease,
+                           QEvent.Type.Wheel,
+                           QEvent.Type.Gesture]
+        ):
+            return True
+        
+        return super().eventFilter(obj, evt)
+    
+    def _keyPressEvent(self, evt):
+        """
+        Protected method called by a key press.
+        
+        @param evt reference to the key event
+        @type QKeyEvent
+        """
+        key = evt.key()
+        isControlModifier = bool(
+            evt.modifiers() & Qt.KeyboardModifier.ControlModifier)
+        
+        if (
+            key == Qt.Key.Key_ZoomIn or
+            (key == Qt.Key.Key_Plus and isControlModifier)
+        ):
+            self.scaleUp()
+            evt.accept()
+        elif (
+            key == Qt.Key.Key_ZoomOut or
+            (key == Qt.Key.Key_Minus and isControlModifier)
+        ):
+            self.scaleDown()
+            evt.accept()
+        elif key == Qt.Key.Key_0 and isControlModifier:
+            self.resetScale()
+            evt.accept()
+        elif (
+            key == Qt.Key.Key_Backspace or
+            (key == Qt.Key.Key_Left and isControlModifier)
+        ):
+            self.backward()
+            evt.accept()
+        elif key == Qt.Key.Key_Right and isControlModifier:
+            self.forward()
+            evt.accept()
+    
+    def _mouseReleaseEvent(self, evt):
+        """
+        Protected method called by a mouse release event.
+        
+        @param evt reference to the mouse event
+        @type QMouseEvent
+        """
+        accepted = evt.isAccepted()
+        self.__page.event(evt)
+        if (
+            not evt.isAccepted() and
+            evt.button() == Qt.MouseButton.MiddleButton
+        ):
+            url = QUrl(QGuiApplication.clipboard().text(
+                QClipboard.Mode.Selection))
+            if (
+                not url.isEmpty() and
+                url.isValid() and
+                url.scheme() != ""
+            ):
+                self.setLink(url)
+                accepted = True
+        evt.setAccepted(accepted)
+    
+    def _wheelEvent(self, evt):
+        """
+        Protected method to handle wheel events.
+        
+        @param evt reference to the wheel event
+        @type QWheelEvent
+        """
+        delta = evt.angleDelta().y()
+        if evt.modifiers() & Qt.KeyboardModifier.ControlModifier:
+            if delta < 0:
+                self.scaleDown()
+            elif delta > 0:
+                self.scaleUp()
+            evt.accept()
+        
+        elif evt.modifiers() & Qt.KeyboardModifier.ShiftModifier:
+            if delta < 0:
+                self.backward()
+            elif delta > 0:
+                self.forward()
+            evt.accept()
+    
+    def _gestureEvent(self, evt):
+        """
+        Protected method handling gesture events.
+        
+        @param evt reference to the gesture event
+        @type QGestureEvent
+        """
+        pinch = evt.gesture(Qt.GestureType.PinchGesture)
+        if pinch:
+            if pinch.state() == Qt.GestureState.GestureStarted:
+                pinch.setTotalScaleFactor(self.__currentScale / 100.0)
+            elif pinch.state() == Qt.GestureState.GestureUpdated:
+                scaleFactor = pinch.totalScaleFactor()
+                self.setScale(int(scaleFactor * 100))
+            evt.accept()
+    
+    def event(self, evt):
+        """
+        Public method handling events.
+        
+        @param evt reference to the event (QEvent)
+        @return flag indicating, if the event was handled (boolean)
+        """
+        if evt.type() == QEvent.Type.Gesture:
+            self._gestureEvent(evt)
+            return True
+        
+        return super().event(evt)
+    
+    #######################################################################
+    ## Context menu related methods below
+    #######################################################################
+    
+    def contextMenuEvent(self, evt):
+        """
+        Protected method called to create a context menu.
+        
+        This method is overridden from QWebEngineView.
+        
+        @param evt reference to the context menu event object
+        @type QContextMenuEvent
+        """
+        pos = evt.pos()
+        reason = evt.reason()
+        QTimer.singleShot(
+            0,
+            lambda: self._contextMenuEvent(QContextMenuEvent(reason, pos)))
+        # needs to be done this way because contextMenuEvent is blocking
+        # the main loop
+    
+    def _contextMenuEvent(self, evt):
+        """
+        Protected method called to create a context menu.
+        
+        @param evt reference to the context menu event object
+            (QContextMenuEvent)
+        """
+        self.__menu.clear()
+        
+        self.__createContextMenu(self.__menu)
+        
+        if not self.__menu.isEmpty():
+            pos = evt.globalPos()
+            self.__menu.popup(QPoint(pos.x(), pos.y() + 1))
+    
+    def __createContextMenu(self, menu):
+        """
+        Private method to populate the context menu.
+        
+        @param menu reference to the menu to be populated
+        @type QMenu
+        """
+        contextMenuData = self.lastContextMenuRequest()
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("back"),
+            self.tr("Backward"),
+            self.backward)
+        act.setEnabled(self.isBackwardAvailable())
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("forward"),
+            self.tr("Forward"),
+            self.forward)
+        act.setEnabled(self.isForwardAvailable())
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("reload"),
+            self.tr("Reload"),
+            self.reload)
+        
+        if (
+            not contextMenuData.linkUrl().isEmpty() and
+            contextMenuData.linkUrl().scheme() != "javascript"
+        ):
+            self.__createLinkContextMenu(menu, contextMenuData)
+        
+        menu.addSeparator()
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("editCopy"),
+            self.tr("Copy Page URL to Clipboard"))
+        act.setData(self.link())
+        act.triggered.connect(
+            functools.partial(self.__copyLink, act))
+        
+        menu.addSeparator()
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("zoomIn"),
+            self.tr("Zoom in"),
+            self.scaleUp)
+        act.setEnabled(self.isScaleUpAvailable())
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("zoomOut"),
+            self.tr("Zoom out"),
+            self.scaleDown)
+        act.setEnabled(self.isScaleDownAvailable())
+        
+        menu.addAction(
+            UI.PixmapCache.getIcon("zoomReset"),
+            self.tr("Zoom reset"),
+            self.resetScale)
+        
+        menu.addSeparator()
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("editCopy"),
+            self.tr("Copy"),
+            self.__copyText)
+        act.setEnabled(bool(contextMenuData.selectedText()))
+        
+        menu.addAction(
+            UI.PixmapCache.getIcon("editSelectAll"),
+            self.tr("Select All"),
+            self.__selectAll)
+        
+        menu.addSeparator()
+        
+        menu.addAction(
+            UI.PixmapCache.getIcon("tabClose"),
+            self.tr('Close'),
+            self.__closePage)
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("tabCloseOther"),
+            self.tr("Close Others"),
+            self.__closeOtherPages)
+        act.setEnabled(self.__helpViewerWidget.openPagesCount() > 1)
+    
+    def __createLinkContextMenu(self, menu, contextMenuData):
+        """
+        Private method to populate the context menu for URLs.
+        
+        @param menu reference to the menu to be populated
+        @type QMenu
+        @param contextMenuData data of the last context menu request
+        @type QWebEngineContextMenuRequest
+        """
+        if not menu.isEmpty():
+            menu.addSeparator()
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("openNewTab"),
+            self.tr("Open Link in New Page"))
+        act.setData(contextMenuData.linkUrl())
+        act.triggered.connect(
+            functools.partial(self.__openLinkInNewPage, act))
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("newWindow"),
+            self.tr("Open Link in Background Page"))
+        act.setData(contextMenuData.linkUrl())
+        act.triggered.connect(
+            functools.partial(self.__openLinkInBackgroundPage, act))
+        
+        menu.addSeparator()
+        
+        act = menu.addAction(
+            UI.PixmapCache.getIcon("editCopy"),
+            self.tr("Copy URL to Clipboard"))
+        act.setData(contextMenuData.linkUrl())
+        act.triggered.connect(
+            functools.partial(self.__copyLink, act))
+    
+    def __openLinkInNewPage(self, act):
+        """
+        Private method called by the context menu to open a link in a new page.
+        
+        @param act reference to the action that triggered
+        @type QAction
+        """
+        url = act.data()
+        if url.isEmpty():
+            return
+        
+        self.__helpViewerWidget.openUrlNewPage(url)
+    
+    def __openLinkInBackgroundPage(self, act):
+        """
+        Private method called by the context menu to open a link in a
+        background page.
+        
+        @param act reference to the action that triggered
+        @type QAction
+        """
+        url = act.data()
+        if url.isEmpty():
+            return
+        
+        self.__helpViewerWidget.openUrlNewBackgroundPage(url)
+    
+    def __copyLink(self, act):
+        """
+        Private method called by the context menu to copy a link to the
+        clipboard.
+        
+        @param act reference to the action that triggered
+        @type QAction
+        """
+        data = act.data()
+        if isinstance(data, QUrl) and data.isEmpty():
+            return
+        
+        if isinstance(data, QUrl):
+            data = data.toString()
+        
+        # copy the URL to both clipboard areas
+        QGuiApplication.clipboard().setText(data, QClipboard.Mode.Clipboard)
+        QGuiApplication.clipboard().setText(data, QClipboard.Mode.Selection)
+    
+    def __copyText(self):
+        """
+        Private method called by the context menu to copy selected text to the
+        clipboard.
+        """
+        self.triggerPageAction(QWebEnginePage.WebAction.Copy)
+    
+    def __selectAll(self):
+        """
+        Private method called by the context menu to select all text.
+        """
+        self.triggerPageAction(QWebEnginePage.WebAction.SelectAll)
+    
+    def __closePage(self):
+        """
+        Private method called by the context menu to close the current page.
+        """
+        self.__helpViewerWidget.closeCurrentPage()
+    
+    def __closeOtherPages(self):
+        """
+        Private method called by the context menu to close all other pages.
+        """
+        self.__helpViewerWidget.closeOtherPages()

eric ide

mercurial