From 86d5d315bc3d5974be279061c1667f4bbf02caaa Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Mon, 1 Nov 2021 17:02:07 +0100 Subject: [PATCH] Differentiate between local and remote packages There is a distinction between packages which are already installed on the local machine and packages which are available on the remote server. Even with this difference it is important that they are handled the same and can be reused in the same GUI elements. In order to reduce code duplication I created a parent object PackageList which contains the base logic and interface for the QML and let both RemotePackageList and LocalPackageList inherit from this. UX specified that the gear icon (Settings.svg) should be separate from the tabs of material and plugins. This also ment that the current tab item couldn't set the pageTitle anymore. This is now defined in the Package component and set when the loader has loaded the external QML file. Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 48 ++++++ plugins/Marketplace/Marketplace.py | 6 +- plugins/Marketplace/PackageList.py | 138 +++--------------- plugins/Marketplace/RemotePackageList.py | 131 +++++++++++++++++ .../resources/qml/ManagedPackages.qml | 16 ++ .../Marketplace/resources/qml/Marketplace.qml | 78 ++++++++-- .../Marketplace/resources/qml/Materials.qml | 5 +- .../Marketplace/resources/qml/Packages.qml | 10 +- plugins/Marketplace/resources/qml/Plugins.qml | 5 +- .../cura-light/icons/default/Settings.svg | 3 + .../themes/cura-light/icons/high/Settings.svg | 3 + 11 files changed, 307 insertions(+), 136 deletions(-) create mode 100644 plugins/Marketplace/LocalPackageList.py create mode 100644 plugins/Marketplace/RemotePackageList.py create mode 100644 plugins/Marketplace/resources/qml/ManagedPackages.qml create mode 100644 resources/themes/cura-light/icons/default/Settings.svg create mode 100644 resources/themes/cura-light/icons/high/Settings.svg diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py new file mode 100644 index 0000000000..ff221ed606 --- /dev/null +++ b/plugins/Marketplace/LocalPackageList.py @@ -0,0 +1,48 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import pyqtSlot, Qt +from typing import TYPE_CHECKING + +from UM.i18n import i18nCatalog + +from cura.CuraApplication import CuraApplication + +from .PackageList import PackageList +from .PackageModel import PackageModel # The contents of this list. + +if TYPE_CHECKING: + from PyQt5.QtCore import QObject + +catalog = i18nCatalog("cura") + + +class LocalPackageList(PackageList): + PackageRole = Qt.UserRole + 1 + + def __init__(self, parent: "QObject" = None) -> None: + super().__init__(parent) + self._application = CuraApplication.getInstance() + + @pyqtSlot() + def updatePackages(self) -> None: + """ + Make a request for the first paginated page of packages. + + When the request is done, the list will get updated with the new package models. + """ + self.setErrorMessage("") # Clear any previous errors. + self.setIsLoading(True) + self._getLocalPackages() + + def _getLocalPackages(self) -> None: + plugin_registry = self._application.getPluginRegistry() + package_manager = self._application.getPackageManager() + + bundled = plugin_registry.getInstalledPlugins() + for b in bundled: + package = PackageModel({"package_id": b, "display_name": b}, parent = self) + self.appendItem({"package": package}) + packages = package_manager.getInstalledPackageIDs() + self.setIsLoading(False) + self.setHasMore(False) diff --git a/plugins/Marketplace/Marketplace.py b/plugins/Marketplace/Marketplace.py index b3f573f792..9418174d19 100644 --- a/plugins/Marketplace/Marketplace.py +++ b/plugins/Marketplace/Marketplace.py @@ -13,7 +13,8 @@ from UM.Extension import Extension # We are implementing the main object of an from UM.Logger import Logger from UM.PluginRegistry import PluginRegistry # To find out where we are stored (the proper way). -from .PackageList import PackageList # To register this type with QML. +from .RemotePackageList import RemotePackageList # To register this type with QML. +from .LocalPackageList import LocalPackageList # To register this type with QML. if TYPE_CHECKING: from PyQt5.QtCore import QObject @@ -31,7 +32,8 @@ class Marketplace(Extension): super().__init__() self._window: Optional["QObject"] = None # If the window has been loaded yet, it'll be cached in here. - qmlRegisterType(PackageList, "Marketplace", 1, 0, "PackageList") + qmlRegisterType(RemotePackageList, "Marketplace", 1, 0, "RemotePackageList") + qmlRegisterType(LocalPackageList, "Marketplace", 1, 0, "LocalPackageList") @pyqtSlot() def show(self) -> None: diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py index 424b66fe21..00532313f0 100644 --- a/plugins/Marketplace/PackageList.py +++ b/plugins/Marketplace/PackageList.py @@ -2,19 +2,10 @@ # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt -from PyQt5.QtNetwork import QNetworkReply -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING -from cura.CuraApplication import CuraApplication -from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization. from UM.i18n import i18nCatalog -from UM.Logger import Logger from UM.Qt.ListModel import ListModel -from UM.TaskManagement.HttpRequestManager import HttpRequestManager, HttpRequestData # To request the package list from the API. -from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope # To request JSON responses from the API. - -from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports. -from .PackageModel import PackageModel # The contents of this list. if TYPE_CHECKING: from PyQt5.QtCore import QObject @@ -23,99 +14,60 @@ catalog = i18nCatalog("cura") class PackageList(ListModel): - """ - Represents a list of packages to be displayed in the interface. - - The list can be filtered (e.g. on package type, materials vs. plug-ins) and - paginated. - """ - PackageRole = Qt.UserRole + 1 - ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once. - def __init__(self, parent: "QObject" = None) -> None: super().__init__(parent) - - self._ongoing_request: Optional[HttpRequestData] = None - self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance())) self._error_message = "" - - self._package_type_filter = "" - self._request_url = self._initialRequestUrl() - self.addRoleName(self.PackageRole, "package") - - def __del__(self) -> None: - """ - When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++ - object. - """ - self.abortRequest() + self._is_loading = False + self._has_more = False @pyqtSlot() - def request(self) -> None: + def updatePackages(self) -> None: """ - Make a request for the first paginated page of packages. - - When the request is done, the list will get updated with the new package models. + Initialize the first page of packages """ self.setErrorMessage("") # Clear any previous errors. - - http = HttpRequestManager.getInstance() - self._ongoing_request = http.get( - self._request_url, - scope = self._scope, - callback = self._parseResponse, - error_callback = self._onError - ) self.isLoadingChanged.emit() @pyqtSlot() - def abortRequest(self) -> None: - HttpRequestManager.getInstance().abortRequest(self._ongoing_request) - self._ongoing_request = None + def abortUpdating(self) -> None: + pass def reset(self) -> None: self.clear() - self._request_url = self._initialRequestUrl() isLoadingChanged = pyqtSignal() - @pyqtProperty(bool, notify = isLoadingChanged) + def setIsLoading(self, value: bool) -> None: + if self._is_loading != value: + self._is_loading = value + self.isLoadingChanged.emit() + + @pyqtProperty(bool, fset = setIsLoading, notify = isLoadingChanged) def isLoading(self) -> bool: """ Gives whether the list is currently loading the first page or loading more pages. - :return: ``True`` if the list is downloading, or ``False`` if not downloading. + :return: ``True`` if the list is being gathered, or ``False`` if . """ - return self._ongoing_request is not None + return self._is_loading hasMoreChanged = pyqtSignal() - @pyqtProperty(bool, notify = hasMoreChanged) + def setHasMore(self, value: bool) -> None: + if self._has_more != value: + self._has_more = value + self.hasMoreChanged.emit() + + @pyqtProperty(bool, fset = setHasMore, notify = hasMoreChanged) def hasMore(self) -> bool: """ Returns whether there are more packages to load. :return: ``True`` if there are more packages to load, or ``False`` if we've reached the last page of the pagination. """ - return self._request_url != "" - - packageTypeFilterChanged = pyqtSignal() - - def setPackageTypeFilter(self, new_filter: str) -> None: - if new_filter != self._package_type_filter: - self._package_type_filter = new_filter - self.reset() - self.packageTypeFilterChanged.emit() - - @pyqtProperty(str, notify = packageTypeFilterChanged, fset = setPackageTypeFilter) - def packageTypeFilter(self) -> str: - """ - Get the package type this package list is filtering on, like ``plugin`` or ``material``. - :return: The package type this list is filtering on. - """ - return self._package_type_filter + return self._has_more def setErrorMessage(self, error_message: str) -> None: if self._error_message != error_message: @@ -133,49 +85,3 @@ class PackageList(ListModel): :return: An error message, if any, or an empty string if everything went okay. """ return self._error_message - - def _initialRequestUrl(self) -> str: - """ - Get the URL to request the first paginated page with. - :return: A URL to request. - """ - if self._package_type_filter != "": - return f"{Marketplace.PACKAGES_URL}?package_type={self._package_type_filter}&limit={self.ITEMS_PER_PAGE}" - return f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}" - - def _parseResponse(self, reply: "QNetworkReply") -> None: - """ - Parse the response from the package list API request. - - This converts that response into PackageModels, and triggers the ListModel to update. - :param reply: A reply containing information about a number of packages. - """ - response_data = HttpRequestManager.readJSON(reply) - if "data" not in response_data or "links" not in response_data: - Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}") - self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response.")) - return - - for package_data in response_data["data"]: - package = PackageModel(package_data, parent = self) - self.appendItem({"package": package}) # Add it to this list model. - - self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page. - self.hasMoreChanged.emit() - self._ongoing_request = None - self.isLoadingChanged.emit() - - def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None: - """ - Handles networking and server errors when requesting the list of packages. - :param reply: The reply with packages. This will most likely be incomplete and should be ignored. - :param error: The error status of the request. - """ - if error == QNetworkReply.NetworkError.OperationCanceledError: - Logger.debug("Cancelled request for packages.") - self._ongoing_request = None - return # Don't show an error about this to the user. - Logger.error("Could not reach Marketplace server.") - self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace.")) - self._ongoing_request = None - self.isLoadingChanged.emit() diff --git a/plugins/Marketplace/RemotePackageList.py b/plugins/Marketplace/RemotePackageList.py new file mode 100644 index 0000000000..3cfb11d6ba --- /dev/null +++ b/plugins/Marketplace/RemotePackageList.py @@ -0,0 +1,131 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot +from PyQt5.QtNetwork import QNetworkReply +from typing import Optional, TYPE_CHECKING + +from cura.CuraApplication import CuraApplication +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization. +from UM.i18n import i18nCatalog +from UM.Logger import Logger +from UM.TaskManagement.HttpRequestManager import HttpRequestManager, HttpRequestData # To request the package list from the API. +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope # To request JSON responses from the API. + +from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports. +from .PackageList import PackageList +from .PackageModel import PackageModel # The contents of this list. + +if TYPE_CHECKING: + from PyQt5.QtCore import QObject + from PyQt5.QtNetwork import QNetworkReply + +catalog = i18nCatalog("cura") + + +class RemotePackageList(PackageList): + ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once. + + def __init__(self, parent: "QObject" = None) -> None: + super().__init__(parent) + + self._ongoing_request: Optional[HttpRequestData] = None + self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance())) + + self._package_type_filter = "" + self._request_url = self._initialRequestUrl() + + def __del__(self) -> None: + """ + When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++ + object. + """ + self.abortUpdating() + + @pyqtSlot() + def updatePackages(self) -> None: + """ + Make a request for the first paginated page of packages. + + When the request is done, the list will get updated with the new package models. + """ + self.setErrorMessage("") # Clear any previous errors. + self.setIsLoading(True) + + self._ongoing_request = HttpRequestManager.getInstance().get( + self._request_url, + scope = self._scope, + callback = self._parseResponse, + error_callback = self._onError + ) + + @pyqtSlot() + def abortUpdating(self) -> None: + HttpRequestManager.getInstance().abortRequest(self._ongoing_request) + self._ongoing_request = None + + def reset(self) -> None: + self.clear() + self._request_url = self._initialRequestUrl() + + packageTypeFilterChanged = pyqtSignal() + + def setPackageTypeFilter(self, new_filter: str) -> None: + if new_filter != self._package_type_filter: + self._package_type_filter = new_filter + self.reset() + self.packageTypeFilterChanged.emit() + + @pyqtProperty(str, fset = setPackageTypeFilter, notify = packageTypeFilterChanged) + def packageTypeFilter(self) -> str: + """ + Get the package type this package list is filtering on, like ``plugin`` or ``material``. + :return: The package type this list is filtering on. + """ + return self._package_type_filter + + def _initialRequestUrl(self) -> str: + """ + Get the URL to request the first paginated page with. + :return: A URL to request. + """ + if self._package_type_filter != "": + return f"{Marketplace.PACKAGES_URL}?package_type={self._package_type_filter}&limit={self.ITEMS_PER_PAGE}" + return f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}" + + def _parseResponse(self, reply: "QNetworkReply") -> None: + """ + Parse the response from the package list API request. + + This converts that response into PackageModels, and triggers the ListModel to update. + :param reply: A reply containing information about a number of packages. + """ + response_data = HttpRequestManager.readJSON(reply) + if "data" not in response_data or "links" not in response_data: + Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}") + self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response.")) + return + + for package_data in response_data["data"]: + package = PackageModel(package_data, parent = self) + self.appendItem({"package": package}) # Add it to this list model. + + self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page. + self._ongoing_request = None + self.setIsLoading(False) + self.setHasMore(self._request_url != "") + + def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None: + """ + Handles networking and server errors when requesting the list of packages. + :param reply: The reply with packages. This will most likely be incomplete and should be ignored. + :param error: The error status of the request. + """ + if error == QNetworkReply.NetworkError.OperationCanceledError: + Logger.debug("Cancelled request for packages.") + self._ongoing_request = None + return # Don't show an error about this to the user. + Logger.error("Could not reach Marketplace server.") + self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace.")) + self._ongoing_request = None + self.setIsLoading(False) diff --git a/plugins/Marketplace/resources/qml/ManagedPackages.qml b/plugins/Marketplace/resources/qml/ManagedPackages.qml new file mode 100644 index 0000000000..243d5bf12e --- /dev/null +++ b/plugins/Marketplace/resources/qml/ManagedPackages.qml @@ -0,0 +1,16 @@ +// Copyright (c) 2021 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import Marketplace 1.0 as Marketplace +import UM 1.4 as UM + +Packages +{ + pageTitle: catalog.i18nc("@header", "Manage packages") + model: Marketplace.LocalPackageList + { + } +} diff --git a/plugins/Marketplace/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml index 6cc4a3622d..7004b69d46 100644 --- a/plugins/Marketplace/resources/qml/Marketplace.qml +++ b/plugins/Marketplace/resources/qml/Marketplace.qml @@ -34,7 +34,8 @@ Window title: "Marketplace" //Seen by Ultimaker as a brand name, so this doesn't get translated. modality: Qt.NonModal - Rectangle //Background color. + // Background color + Rectangle { anchors.fill: parent color: UM.Theme.getColor("main_background") @@ -45,13 +46,15 @@ Window spacing: UM.Theme.getSize("default_margin").height - Item //Page title. + // Page title. + Item { Layout.preferredWidth: parent.width Layout.preferredHeight: childrenRect.height + UM.Theme.getSize("default_margin").height Label { + id: pageTitle anchors { left: parent.left @@ -63,7 +66,7 @@ Window font: UM.Theme.getFont("large") color: UM.Theme.getColor("text") - text: pageSelectionTabBar.currentItem.pageTitle + text: "" } } @@ -72,10 +75,56 @@ Window Layout.preferredWidth: parent.width Layout.preferredHeight: childrenRect.height - TabBar //Page selection. + Button + { + id: managePackagesButton + + hoverEnabled: true + + width: childrenRect.width + height: childrenRect.height + + anchors.right: parent.right + anchors.rightMargin: UM.Theme.getSize("default_margin").width + + background: Rectangle + { + color: UM.Theme.getColor("action_button") + border.color: "transparent" + border.width: UM.Theme.getSize("default_lining").width + } + + Cura.ToolTip + { + id: managePackagesTooltip + + tooltipText: catalog.i18nc("@info:tooltip", "Manage packages") + arrowSize: 0 + visible: managePackagesButton.hovered + } + + UM.RecolorImage + { + id: managePackagesIcon + + width: UM.Theme.getSize("section_icon").width + height: UM.Theme.getSize("section_icon").height + + color: UM.Theme.getColor("icon") + source: UM.Theme.getIcon("Settings") + } + + onClicked: + { + content.source = "ManagedPackages.qml" + } + } + + // Page selection. + TabBar { id: pageSelectionTabBar - anchors.right: parent.right + anchors.right: managePackagesButton.left anchors.rightMargin: UM.Theme.getSize("default_margin").width spacing: 0 @@ -84,33 +133,42 @@ Window { width: implicitWidth text: catalog.i18nc("@button", "Plug-ins") - pageTitle: catalog.i18nc("@header", "Install Plugins") onClicked: content.source = "Plugins.qml" } PackageTypeTab { width: implicitWidth text: catalog.i18nc("@button", "Materials") - pageTitle: catalog.i18nc("@header", "Install Materials") onClicked: content.source = "Materials.qml" } } } - Rectangle //Page contents. + // Page contents. + Rectangle { Layout.preferredWidth: parent.width Layout.fillHeight: true color: UM.Theme.getColor("detail_background") - Loader //Page contents. + // Page contents. + Loader { id: content anchors.fill: parent anchors.margins: UM.Theme.getSize("default_margin").width source: "Plugins.qml" + + Connections + { + target: content + onLoaded: function() + { + pageTitle.text = content.item.pageTitle + } + } } } } } -} \ No newline at end of file +} diff --git a/plugins/Marketplace/resources/qml/Materials.qml b/plugins/Marketplace/resources/qml/Materials.qml index 4f3c59d9fb..1d1572976a 100644 --- a/plugins/Marketplace/resources/qml/Materials.qml +++ b/plugins/Marketplace/resources/qml/Materials.qml @@ -5,8 +5,9 @@ import Marketplace 1.0 as Marketplace Packages { - model: Marketplace.PackageList + pageTitle: catalog.i18nc("@header", "Install Materials") + model: Marketplace.RemotePackageList { packageTypeFilter: "material" } -} \ No newline at end of file +} diff --git a/plugins/Marketplace/resources/qml/Packages.qml b/plugins/Marketplace/resources/qml/Packages.qml index 5442247668..cdc066626c 100644 --- a/plugins/Marketplace/resources/qml/Packages.qml +++ b/plugins/Marketplace/resources/qml/Packages.qml @@ -12,9 +12,10 @@ ScrollView ScrollBar.horizontal.policy: ScrollBar.AlwaysOff property alias model: packagesListview.model + property string pageTitle - Component.onCompleted: model.request() - Component.onDestruction: model.abortRequest() + Component.onCompleted: model.updatePackages() + Component.onDestruction: model.abortUpdating() ListView { @@ -43,7 +44,8 @@ ScrollView } } - footer: Item //Wrapper item to add spacing between content and footer. + //Wrapper item to add spacing between content and footer. + footer: Item { width: parent.width height: UM.Theme.getSize("card").height + packagesListview.spacing @@ -55,7 +57,7 @@ ScrollView anchors.bottom: parent.bottom enabled: packages.model.hasMore && !packages.model.isLoading || packages.model.errorMessage != "" - onClicked: packages.model.request() //Load next page in plug-in list. + onClicked: packages.model.updatePackages() //Load next page in plug-in list. background: Rectangle { diff --git a/plugins/Marketplace/resources/qml/Plugins.qml b/plugins/Marketplace/resources/qml/Plugins.qml index 71814f54ad..ef5d92c2e8 100644 --- a/plugins/Marketplace/resources/qml/Plugins.qml +++ b/plugins/Marketplace/resources/qml/Plugins.qml @@ -5,8 +5,9 @@ import Marketplace 1.0 as Marketplace Packages { - model: Marketplace.PackageList + pageTitle: catalog.i18nc("@header", "Install Plugins") + model: Marketplace.RemotePackageList { packageTypeFilter: "plugin" } -} \ No newline at end of file +} diff --git a/resources/themes/cura-light/icons/default/Settings.svg b/resources/themes/cura-light/icons/default/Settings.svg new file mode 100644 index 0000000000..feb0ab0cc8 --- /dev/null +++ b/resources/themes/cura-light/icons/default/Settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/themes/cura-light/icons/high/Settings.svg b/resources/themes/cura-light/icons/high/Settings.svg new file mode 100644 index 0000000000..1cd2ff324e --- /dev/null +++ b/resources/themes/cura-light/icons/high/Settings.svg @@ -0,0 +1,3 @@ + + +