Addon Manager: Replace QtWebEngine with QTextBrowser

Macro display is still a work-in-progress.
This commit is contained in:
Chris Hennes
2024-01-22 22:07:35 -06:00
parent c7ac9ee848
commit 7b3ff9d9f3
3 changed files with 128 additions and 296 deletions

View File

@@ -28,6 +28,7 @@ SET(AddonManager_SRCS
addonmanager_macro_parser.py
addonmanager_metadata.py
addonmanager_pyside_interface.py
addonmanager_readme_viewer.py
addonmanager_update_all_gui.py
addonmanager_uninstaller.py
addonmanager_uninstaller_gui.py

View File

@@ -0,0 +1,119 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2024 The FreeCAD Project Association AISBL *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" A Qt Widget for displaying Addon README information """
import Addon
from PySide import QtCore, QtGui, QtWidgets
import addonmanager_freecad_interface as fci
import addonmanager_utilities as utils
import NetworkManager
translate = fci.translate
class ReadmeViewer(QtWidgets.QTextBrowser):
"""A QTextBrowser widget that, when given an Addon, downloads the README data as appropriate
and renders it with whatever technology is available (usually Qt's Markdown renderer for
workbenches and its HTML renderer for Macros)."""
def __init__(self, parent=None):
super().__init__(parent)
NetworkManager.InitializeNetworkManager()
NetworkManager.AM_NETWORK_MANAGER.completed.connect(self._download_completed)
self.request_index = 0
self.url = ""
self.repo: Addon.Addon = None
self.setOpenExternalLinks(True)
self.setOpenLinks(True)
self.image_map = {}
def set_addon(self, repo: Addon):
"""Set which Addon's information is displayed"""
self.setPlainText(translate("AddonsInstaller", "Loading README data..."))
self.repo = repo
if self.repo.repo_type == Addon.Addon.Kind.MACRO:
self.url = self.repo.macro.wiki
if not self.url:
self.url = self.repo.macro.url
else:
self.url = utils.get_readme_url(repo)
self.request_index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(self.url)
def _download_completed(self, index: int, code: int, data: QtCore.QByteArray) -> None:
"""Callback for handling a completed README file download."""
if index == self.request_index:
if code == 200: # HTTP success
self._process_package_download(data.data().decode("utf-8"))
else:
self.setPlainText(
translate(
"AddonsInstaller",
"Failed to download data from {} -- received response code {}.",
).format(self.url, code)
)
def _process_package_download(self, data: str):
if self.repo.repo_type == Addon.Addon.Kind.MACRO:
self.setHtml(data)
else:
if hasattr(self, "setMarkdown"):
self.setMarkdown(data)
else:
self.setPlainText(data)
def loadResource(self, resource_type: int, name: QtCore.QUrl) -> object:
"""Callback for resource loading. Called automatically by underlying Qt
code when external resources are needed for rendering. In particular,
here it is used to download and cache (in RAM) the images needed for the
README and Wiki pages."""
if resource_type == QtGui.QTextDocument.ImageResource:
full_url = self._create_full_url(name.toString())
if full_url not in self.image_map:
self.image_map[full_url] = None
fci.Console.PrintMessage(f"Downloading image from {full_url}...\n")
data = NetworkManager.AM_NETWORK_MANAGER.blocking_get(full_url)
if data and data.data():
image = QtGui.QImage.fromData(data.data())
if image:
self.image_map[full_url] = self._ensure_appropriate_width(image)
return self.image_map[full_url]
return super().loadResource(resource_type, name)
def _create_full_url(self, url: str) -> str:
if url.startswith("http"):
return url
if not self.url:
return url
lhs, slash, _ = self.url.rpartition("/")
return lhs + slash + url
def _ensure_appropriate_width(self, image: QtGui.QImage) -> QtGui.QImage:
ninety_seven_percent = self.width() * 0.97
if image.width() < ninety_seven_percent:
return image
return image.scaledToWidth(ninety_seven_percent)

View File

@@ -33,6 +33,7 @@ import addonmanager_freecad_interface as fci
import addonmanager_utilities as utils
from addonmanager_metadata import Version, UrlType, get_first_supported_freecad_version
from addonmanager_workers_startup import GetMacroDetailsWorker, CheckSingleUpdateWorker
from addonmanager_readme_viewer import ReadmeViewer
from Addon import Addon
from change_branch import ChangeBranchDialog
@@ -50,20 +51,6 @@ translate = fci.translate
show_javascript_console_output = False
try:
from PySide import QtWebEngineWidgets
HAS_QTWEBENGINE = True
except ImportError:
fci.Console.PrintWarning(
translate(
"AddonsInstaller",
"Addon Manager Warning: Could not import QtWebEngineWidgets -- README data will display as text-only",
)
+ "\n"
)
HAS_QTWEBENGINE = False
class PackageDetails(QtWidgets.QWidget):
"""The PackageDetails QWidget shows package README information and provides
@@ -95,17 +82,6 @@ class PackageDetails(QtWidgets.QWidget):
self.ui.buttonChangeBranch.clicked.connect(self.change_branch_clicked)
self.ui.buttonEnable.clicked.connect(self.enable_clicked)
self.ui.buttonDisable.clicked.connect(self.disable_clicked)
if HAS_QTWEBENGINE:
self.ui.webView.loadStarted.connect(self.load_started)
self.ui.webView.loadProgress.connect(self.load_progress)
self.ui.webView.loadFinished.connect(self.load_finished)
loading_html_file = os.path.join(os.path.dirname(__file__), "loading.html")
with open(loading_html_file, "r", errors="ignore", encoding="utf-8") as f:
html = f.read()
self.ui.loadingLabel.setHtml(html)
self.ui.loadingLabel.show()
self.ui.webView.hide()
def show_repo(self, repo: Addon, reload: bool = False) -> None:
"""The main entry point for this class, shows the package details and related buttons
@@ -117,16 +93,6 @@ class PackageDetails(QtWidgets.QWidget):
if self.repo != repo or reload:
self.repo = repo
if HAS_QTWEBENGINE:
self.ui.loadingLabel.show()
self.ui.slowLoadLabel.hide()
self.ui.webView.setHtml("<html><body>Loading...</body></html>")
self.ui.webView.hide()
self.ui.progressBar.show()
self.timeout = QtCore.QTimer.singleShot(6000, self.long_load_running) # Six seconds
else:
self.ui.missingWebViewLabel.setStyleSheet("color:" + utils.warning_color_string())
if self.worker is not None:
if not self.worker.isFinished():
self.worker.requestInterruption()
@@ -420,33 +386,13 @@ class PackageDetails(QtWidgets.QWidget):
def show_workbench(self, repo: Addon) -> None:
"""loads information of a given workbench"""
url = utils.get_readme_html_url(repo)
if HAS_QTWEBENGINE:
self.ui.webView.load(QtCore.QUrl(url))
self.ui.urlBar.setText(url)
else:
readme_data = utils.blocking_get(url)
text = readme_data.decode("utf8")
self.ui.textBrowserReadMe.setHtml(text)
self.ui.textBrowserReadMe.set_addon(repo)
def show_package(self, repo: Addon) -> None:
"""Show the details for a package (a repo with a package.xml metadata file)"""
readme_url = None
if repo.metadata:
for url in repo.metadata.url:
if url.type == UrlType.readme:
readme_url = url.location
break
if not readme_url:
readme_url = utils.get_readme_html_url(repo)
if HAS_QTWEBENGINE:
self.ui.webView.load(QtCore.QUrl(readme_url))
self.ui.urlBar.setText(readme_url)
else:
readme_data = utils.blocking_get(readme_url)
text = readme_data.decode("utf8")
self.ui.textBrowserReadMe.setHtml(text)
self.ui.textBrowserReadMe.set_addon(repo)
def show_macro(self, repo: Addon) -> None:
"""loads information of a given macro"""
@@ -461,136 +407,8 @@ class PackageDetails(QtWidgets.QWidget):
def macro_readme_updated(self):
"""Update the display of a Macro's README data."""
url = self.repo.macro.wiki
if not url:
url = self.repo.macro.url
if HAS_QTWEBENGINE:
if url:
self.ui.webView.load(QtCore.QUrl(url))
self.ui.urlBar.setText(url)
else:
self.ui.urlBar.setText(
"("
+ translate("AddonsInstaller", "No URL or wiki page provided by this macro")
+ ")"
)
else:
if url:
readme_data = utils.blocking_get(url)
text = readme_data.decode("utf8")
self.ui.textBrowserReadMe.setHtml(text)
else:
self.ui.textBrowserReadMe.setHtml(
"("
+ translate("AddonsInstaller", "No URL or wiki page provided by this macro")
+ ")"
)
def run_javascript(self):
"""Modify the page for a README to optimize for viewing in a smaller window"""
s = """
( function() {
const url = new URL (window.location);
const body = document.getElementsByTagName("body")[0];
if (url.hostname === "github.com") {
const articles = document.getElementsByTagName("article");
if (articles.length > 0) {
const article = articles[0];
body.appendChild (article);
body.style.padding = "1em";
let sibling = article.previousSibling;
while (sibling) {
sibling.remove();
sibling = article.previousSibling;
}
}
} else if (url.hostname === "gitlab.com" ||
url.hostname === "framagit.org" ||
url.hostname === "salsa.debian.org") {
// These all use the GitLab page display...
const articles = document.getElementsByTagName("article");
if (articles.length > 0) {
const article = articles[0];
body.appendChild (article);
body.style.padding = "1em";
let sibling = article.previousSibling;
while (sibling) {
sibling.remove();
sibling = article.previousSibling;
}
}
} else if (url.hostname === "wiki.freecad.org" ||
url.hostname === "wiki.freecad.org") {
const first_heading = document.getElementById('firstHeading');
const body_content = document.getElementById('bodyContent');
const new_node = document.createElement("div");
new_node.appendChild(first_heading);
new_node.appendChild(body_content);
body.appendChild(new_node);
let sibling = new_node.previousSibling;
while (sibling) {
sibling.remove();
sibling = new_node.previousSibling;
}
}
}
) ()
"""
self.ui.webView.page().runJavaScript(s)
def load_started(self):
"""Called when loading is started: sets up the progress bar"""
self.ui.progressBar.show()
self.ui.progressBar.setValue(0)
def load_progress(self, progress: int):
"""Called during load to update the progress bar"""
self.ui.progressBar.setValue(progress)
def load_finished(self, load_succeeded: bool):
"""Once loading is complete, update the display of the progress bar and loading widget."""
self.ui.loadingLabel.hide()
self.ui.slowLoadLabel.hide()
self.ui.webView.show()
self.ui.progressBar.hide()
url = self.ui.webView.url()
if (
hasattr(self, "timeout")
and hasattr(self.timeout, "isActive")
and self.timeout.isActive()
):
self.timeout.stop()
if load_succeeded:
# It says it succeeded, but it might have only succeeded in loading a
# "Page not found" page!
title = self.ui.webView.title()
path_components = url.path().split("/")
expected_content = path_components[-1]
if url.host() == "github.com" and expected_content not in title:
self.show_error_for(url)
elif title == "":
self.show_error_for(url)
else:
self.run_javascript()
else:
self.show_error_for(url)
def long_load_running(self):
"""Displays a message about loading taking a long time."""
if hasattr(self.ui, "webView") and self.ui.webView.isHidden():
self.ui.slowLoadLabel.show()
self.ui.loadingLabel.hide()
self.ui.webView.show()
def show_error_for(self, url: QtCore.QUrl) -> None:
"""Displays error information."""
m = translate("AddonsInstaller", "Could not load README data from URL {}").format(
url.toString()
)
html = f"<html><body><p>{m}</p></body></html>"
self.ui.webView.setHtml(html)
self.ui.textBrowserReadMe.set_addon(self.repo)
def change_branch_clicked(self) -> None:
"""Loads the branch-switching dialog"""
@@ -671,57 +489,6 @@ class PackageDetails(QtWidgets.QWidget):
self.update_status.emit(self.repo)
if HAS_QTWEBENGINE:
class RestrictedWebPage(QtWebEngineWidgets.QWebEnginePage):
"""A class that follows links to FreeCAD wiki pages, but opens all other
clicked links in the system web browser"""
def __init__(self, parent):
super().__init__(parent)
self.settings().setAttribute(
QtWebEngineWidgets.QWebEngineSettings.ErrorPageEnabled, False
)
self.stored_url = None
def acceptNavigationRequest(self, requested_url, _type, isMainFrame):
"""A callback for navigation requests: this widget will only display
navigation requests to the FreeCAD Wiki (for translation purposes) --
anything else will open in a new window.
"""
if _type == QtWebEngineWidgets.QWebEnginePage.NavigationTypeLinkClicked:
# See if the link is to a FreeCAD Wiki page -- if so, follow it,
# otherwise ask the OS to open it
if (
requested_url.host() == "wiki.freecad.org"
or requested_url.host() == "wiki.freecad.org"
):
return super().acceptNavigationRequest(requested_url, _type, isMainFrame)
QtGui.QDesktopServices.openUrl(requested_url)
self.stored_url = self.url()
QtCore.QTimer.singleShot(0, self._reload_stored_url)
return False
return super().acceptNavigationRequest(requested_url, _type, isMainFrame)
def javaScriptConsoleMessage(self, level, message, lineNumber, _):
"""Handle JavaScript console messages by optionally outputting them to
the FreeCAD Console. This must be manually enabled in this Python file by
setting the global show_javascript_console_output to true."""
global show_javascript_console_output
if show_javascript_console_output:
tag = translate("AddonsInstaller", "Page JavaScript reported")
if level == QtWebEngineWidgets.QWebEnginePage.InfoMessageLevel:
fci.Console.PrintMessage(f"{tag} {lineNumber}: {message}\n")
elif level == QtWebEngineWidgets.QWebEnginePage.WarningMessageLevel:
fci.Console.PrintWarning(f"{tag} {lineNumber}: {message}\n")
elif level == QtWebEngineWidgets.QWebEnginePage.ErrorMessageLevel:
fci.Console.PrintError(f"{tag} {lineNumber}: {message}\n")
def _reload_stored_url(self):
if self.stored_url:
self.load(self.stored_url)
class Ui_PackageDetails(object):
"""The generated UI from the Qt Designer UI file"""
@@ -810,48 +577,10 @@ class Ui_PackageDetails(object):
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
if HAS_QTWEBENGINE:
self.webView = QtWebEngineWidgets.QWebEngineView(PackageDetails)
self.webView.setObjectName("webView")
self.webView.setSizePolicy(sizePolicy1)
self.webView.setPage(RestrictedWebPage(PackageDetails))
self.textBrowserReadMe = ReadmeViewer(PackageDetails)
self.textBrowserReadMe.setObjectName("textBrowserReadMe")
self.verticalLayout_2.addWidget(self.webView)
self.loadingLabel = QtWebEngineWidgets.QWebEngineView(PackageDetails)
self.loadingLabel.setObjectName("loadingLabel")
self.loadingLabel.setSizePolicy(sizePolicy1)
self.verticalLayout_2.addWidget(self.loadingLabel)
self.slowLoadLabel = QtWidgets.QLabel(PackageDetails)
self.slowLoadLabel.setObjectName("slowLoadLabel")
self.verticalLayout_2.addWidget(self.slowLoadLabel)
self.progressBar = QtWidgets.QProgressBar(PackageDetails)
self.progressBar.setObjectName("progressBar")
self.progressBar.setTextVisible(False)
self.verticalLayout_2.addWidget(self.progressBar)
self.urlBar = QtWidgets.QLineEdit(PackageDetails)
self.urlBar.setObjectName("urlBar")
self.urlBar.setReadOnly(True)
self.verticalLayout_2.addWidget(self.urlBar)
else:
self.missingWebViewLabel = QtWidgets.QLabel(PackageDetails)
self.missingWebViewLabel.setObjectName("missingWebViewLabel")
self.missingWebViewLabel.setWordWrap(True)
self.verticalLayout_2.addWidget(self.missingWebViewLabel)
self.textBrowserReadMe = QtWidgets.QTextBrowser(PackageDetails)
self.textBrowserReadMe.setObjectName("textBrowserReadMe")
self.textBrowserReadMe.setOpenExternalLinks(True)
self.textBrowserReadMe.setOpenLinks(True)
self.verticalLayout_2.addWidget(self.textBrowserReadMe)
self.verticalLayout_2.addWidget(self.textBrowserReadMe)
self.retranslateUi(PackageDetails)
@@ -888,22 +617,5 @@ class Ui_PackageDetails(object):
self.buttonBack.setToolTip(
QtCore.QCoreApplication.translate("AddonsInstaller", "Return to package list", None)
)
if not HAS_QTWEBENGINE:
self.missingWebViewLabel.setText(
"<h3>"
+ QtCore.QCoreApplication.translate(
"AddonsInstaller",
"QtWebEngine Python bindings not installed -- using fallback README display.",
None,
)
+ "</h3>"
)
else:
self.slowLoadLabel.setText(
QtCore.QCoreApplication.translate(
"AddonsInstaller",
"The page is taking a long time to load... showing the data we have so far...",
)
)
# retranslateUi