Sun, 17 Oct 2021 15:26:31 +0200
Continued implementing the embedded help viewer widget. Added context menus to the help viewer variants.
# -*- 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()