Sun, 17 Oct 2021 16:00:54 +0200
Added a few TODO markers.
# -*- coding: utf-8 -*- # Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing the QTextBrowser based help viewer class. """ import functools from PyQt6.QtCore import ( pyqtSlot, Qt, QByteArray, QUrl, QEvent, QCoreApplication, QPoint ) from PyQt6.QtGui import QDesktopServices, QImage, QGuiApplication, QClipboard from PyQt6.QtWidgets import QTextBrowser, QMenu from .HelpViewerImpl import HelpViewerImpl import UI.PixmapCache AboutBlank = QCoreApplication.translate( "HelpViewer", "<html>" "<head><title>about:blank</title></head>" "<body></body>" "</html>") PageNotFound = QCoreApplication.translate( "HelpViewer", """<html>""" """<head><title>Error 404...</title></head>""" """<body><div align="center"><br><br>""" """<h1>The page could not be found</h1><br>""" """<h3>'{0}'</h3></div></body>""" """</html>""") class HelpViewerImplQTB(HelpViewerImpl, QTextBrowser): """ Class implementing the QTextBrowser based help viewer class. """ 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 """ QTextBrowser.__init__(self, parent=parent) HelpViewerImpl.__init__(self, engine) self.__helpViewerWidget = parent self.__zoomCount = 0 self.__menu = QMenu(self) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.__showContextMenu) self.sourceChanged.connect(self.titleChanged) self.grabGesture(Qt.GestureType.PinchGesture) def setLink(self, url): """ Public method to set the URL of the document to be shown. @param url source of the document @type QUrl """ self.setSource(url) def link(self): """ Public method to get the URL of the shown document. @return URL of the document @rtype QUrl """ return self.source() def doSetSource(self, url, type_): """ Public method to load the data and show it. @param url URL of resource to load @type QUrl @param type_ type of the resource to load @type QTextDocument.ResourceType """ data = self.__getData(url) if data is None: QDesktopServices.openUrl(url) return if url != QUrl("about:blank"): super().doSetSource(url, type) self.setHtml(data) self.sourceChanged.emit(url) self.loadFinished.emit(True) def __getData(self, url): """ Private method to get the data to be shown. @param url URL to be loaded @type QUrl @return data to be shown @rtype str """ scheme = url.scheme() if scheme == "about": if url.toString() == "about:blank": return AboutBlank else: return PageNotFound.format(url.toString()) elif scheme in ("file", ""): filePath = url.toLocalFile() try: with open(filePath, "r", encoding="utf-8") as f: htmlText = f.read() return htmlText except OSError: return PageNotFound.format(url.toString()) elif scheme == "qthelp": if self._engine.findFile(url).isValid(): data = bytes(self._engine.fileData(url)).decode("utf-8") if not data: data = PageNotFound.format(url.toString()) return data else: return PageNotFound.format(url.toString()) else: # None is an indicator that we cannot handle the request return None def pageTitle(self): """ Public method get the page title. @return page title @rtype str """ titleStr = self.documentTitle() if not titleStr: 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 def loadResource(self, type_, name): """ Public method to load data of the specified type from the resource with the given name. @param type_ resource type @type int @param name resource name @type QUrl @return byte array containing the loaded data @rtype QByteArray """ ba = QByteArray() if type_ < 4: # QTextDocument.ResourceType.MarkdownResource # TODO: change to use getData() url = self._engine.findFile(name) ba = self._engine.fileData(url) if url.toString().lower().endswith(".svg"): image = QImage() image.loadFromData(ba, "svg") if not image.isNull(): return image return ba def mousePressEvent(self, evt): """ Protected method called by a mouse press event. @param evt reference to the mouse event @type QMouseEvent """ if evt.button() == Qt.MouseButton.XButton1: self.backward() evt.accept() elif evt.button() == Qt.MouseButton.XButton2: self.forward() evt.accept() else: super().mousePressEvent(evt) def mouseReleaseEvent(self, evt): """ Protected method called by a mouse release event. @param evt reference to the mouse event @type QMouseEvent """ hasModifier = evt.modifiers() != Qt.KeyboardModifier.NoModifier if evt.button() == Qt.MouseButton.LeftButton and hasModifier: anchor = self.anchorAt(evt.pos()) if anchor: url = self.link().resolved(QUrl(anchor)) if evt.modifiers() & Qt.KeyboardModifier.ControlModifier: self.__helpViewerWidget.openUrlNewBackgroundPage(url) else: self.__helpViewerWidget.openUrlNewPage(url) evt.accept() else: super().mousePressEvent(evt) def gotoHistory(self, index): """ Public method to step through the history. @param index history index (<0 backward, >0 forward) @type int """ if index < 0: # backward for _ind in range(-index): self.backward() else: # forward for _ind in range(index): self.forward() 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 QTextBrowser.isBackwardAvailable(self) 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 QTextBrowser.isForwardAvailable(self) def scaleUp(self): """ Public method to zoom in. """ if self.__zoomCount < 10: self.__zoomCount += 1 self.zoomIn() self.zoomChanged.emit() def scaleDown(self): """ Public method to zoom out. """ if self.__zoomCount > -5: self.__zoomCount -= 1 self.zoomOut() self.zoomChanged.emit() def setScale(self, scale): """ Public method to set the zoom level. @param scale zoom level to set @type int """ if -5 <= scale <= 10: self.zoomOut(scale) self.__zoomCount = scale self.zoomChanged.emit() def resetScale(self): """ Public method to reset the zoom level. """ if self.__zoomCount != 0: self.zoomOut(self.__zoomCount) self.zoomChanged.emit() self.__zoomCount = 0 def scale(self): """ Public method to get the zoom level. @return current zoom level @rtype int """ return self.__zoomCount def isScaleUpAvailable(self): """ Public method to check, if the max. zoom level is reached. @return flag indicating scale up is available @rtype bool """ return self.__zoomCount < 10 def isScaleDownAvailable(self): """ Public method to check, if the min. zoom level is reached. @return flag indicating scale down is available @rtype bool """ return self.__zoomCount > -5 def wheelEvent(self, evt): """ Protected method to handle wheel event to zoom. @param evt reference to the event object @type QWheelEvent """ delta = evt.angleDelta().y() if evt.modifiers() == Qt.KeyboardModifier.ControlModifier: if delta > 0: self.scaleUp() else: self.scaleDown() evt.accept() elif evt.modifiers() & Qt.KeyboardModifier.ShiftModifier: if delta < 0: self.backward() elif delta > 0: self.forward() evt.accept() else: QTextBrowser.wheelEvent(self, evt) def keyPressEvent(self, evt): """ Protected method to handle key press events. @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() else: super().keyPressEvent(evt) def event(self, evt): """ Public method handling events. @param evt reference to the event @type QEvent @return flag indicating the event was handled @rtype bool """ if evt.type() == QEvent.Type.Gesture: self.gestureEvent(evt) return True return super().event(evt) 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: zoom = (self.getZoom() + 6) / 10.0 pinch.setTotalScaleFactor(zoom) elif pinch.state() == Qt.GestureState.GestureUpdated: zoom = int(pinch.totalScaleFactor() * 10) - 6 if zoom <= -5: zoom = -5 pinch.setTotalScaleFactor(0.1) elif zoom >= 10: zoom = 10 pinch.setTotalScaleFactor(1.6) self.setScale(zoom) evt.accept() ####################################################################### ## Context menu related methods below ####################################################################### @pyqtSlot(QPoint) def __showContextMenu(self, pos): """ Private slot to show the context menu. @param pos position to show the context menu at @type QPoint """ self.__menu.clear() anchor = self.anchorAt(pos) linkUrl = self.link().resolved(QUrl(anchor)) if anchor else QUrl() selectedText = self.textCursor().selectedText() act = self.__menu.addAction( UI.PixmapCache.getIcon("back"), self.tr("Backward"), self.backward) act.setEnabled(self.isBackwardAvailable()) act = self.__menu.addAction( UI.PixmapCache.getIcon("forward"), self.tr("Forward"), self.forward) act.setEnabled(self.isForwardAvailable()) act = self.__menu.addAction( UI.PixmapCache.getIcon("reload"), self.tr("Reload"), self.reload) if not linkUrl.isEmpty() and linkUrl.scheme() != "javascript": self.__createLinkContextMenu(self.__menu, linkUrl) self.__menu.addSeparator() act = self.__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)) self.__menu.addSeparator() act = self.__menu.addAction( UI.PixmapCache.getIcon("zoomIn"), self.tr("Zoom in"), self.scaleUp) act.setEnabled(self.isScaleUpAvailable()) act = self.__menu.addAction( UI.PixmapCache.getIcon("zoomOut"), self.tr("Zoom out"), self.scaleDown) act.setEnabled(self.isScaleDownAvailable()) self.__menu.addAction( UI.PixmapCache.getIcon("zoomReset"), self.tr("Zoom reset"), self.resetScale) self.__menu.addSeparator() act = self.__menu.addAction( UI.PixmapCache.getIcon("editCopy"), self.tr("Copy"), self.copy) act.setEnabled(bool(selectedText)) self.__menu.addAction( UI.PixmapCache.getIcon("editSelectAll"), self.tr("Select All"), self.selectAll) self.__menu.addSeparator() self.__menu.addAction( UI.PixmapCache.getIcon("tabClose"), self.tr('Close'), self.__closePage) act = self.__menu.addAction( UI.PixmapCache.getIcon("tabCloseOther"), self.tr("Close Others"), self.__closeOtherPages) act.setEnabled(self.__helpViewerWidget.openPagesCount() > 1) self.__menu.popup(self.mapToGlobal(pos)) def __createLinkContextMenu(self, menu, linkUrl): """ Private method to populate the context menu for URLs. @param menu reference to the menu to be populated @type QMenu @param linkUrl URL to create the menu part for @type QUrl """ if not menu.isEmpty(): menu.addSeparator() act = menu.addAction( UI.PixmapCache.getIcon("openNewTab"), self.tr("Open Link in New Page")) act.setData(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(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(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 __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()