diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py index b93cec1183..424b66fe21 100644 --- a/plugins/Marketplace/PackageList.py +++ b/plugins/Marketplace/PackageList.py @@ -2,6 +2,7 @@ # 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 cura.CuraApplication import CuraApplication @@ -9,7 +10,7 @@ from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To ma from UM.i18n import i18nCatalog from UM.Logger import Logger from UM.Qt.ListModel import ListModel -from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To request the package list from the API. +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. @@ -17,7 +18,6 @@ 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") @@ -33,19 +33,25 @@ class PackageList(ListModel): PackageRole = Qt.UserRole + 1 ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once. - INCLUDED_PACKAGE_TYPE = ("material", "plugin") # Only show these kind of packages def __init__(self, parent: "QObject" = None) -> None: super().__init__(parent) - self._is_loading = True + self._ongoing_request: Optional[HttpRequestData] = None self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance())) - self._request_url = f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}" self._error_message = "" + self._package_type_filter = "" + self._request_url = self._initialRequestUrl() + self.addRoleName(self.PackageRole, "package") - self.request() + 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() @pyqtSlot() def request(self) -> None: @@ -54,31 +60,35 @@ class PackageList(ListModel): When the request is done, the list will get updated with the new package models. """ - self.setIsLoading(True) self.setErrorMessage("") # Clear any previous errors. http = HttpRequestManager.getInstance() - http.get( + 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 reset(self) -> None: + self.clear() + self._request_url = self._initialRequestUrl() isLoadingChanged = pyqtSignal() - def setIsLoading(self, is_loading: bool) -> None: - if is_loading != self._is_loading: - self._is_loading = is_loading - self.isLoadingChanged.emit() - - @pyqtProperty(bool, notify = isLoadingChanged, fset = setIsLoading) + @pyqtProperty(bool, 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 self._is_loading + return self._ongoing_request is not None hasMoreChanged = pyqtSignal() @@ -91,6 +101,22 @@ class PackageList(ListModel): """ 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 + def setErrorMessage(self, error_message: str) -> None: if self._error_message != error_message: self._error_message = error_message @@ -108,6 +134,15 @@ class PackageList(ListModel): """ 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. @@ -122,19 +157,25 @@ class PackageList(ListModel): return for package_data in response_data["data"]: - if package_data["package_type"] in self.INCLUDED_PACKAGE_TYPE: - package = PackageModel(package_data, parent = self) - self.appendItem({"package": package}) # Add it to this list model. + 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.setIsLoading(False) + self._ongoing_request = None + self.isLoadingChanged.emit() - def _onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + 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. """ - Logger.error(f"Could not reach Marketplace server.") + 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/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml index 2213b3d456..33cae9e778 100644 --- a/plugins/Marketplace/resources/qml/Marketplace.qml +++ b/plugins/Marketplace/resources/qml/Marketplace.qml @@ -66,6 +66,35 @@ Window text: catalog.i18nc("@header", "Install Plugins") } } + + Item + { + Layout.preferredWidth: parent.width + Layout.preferredHeight: childrenRect.height + + TabBar //Page selection. + { + id: pageSelectionTabBar + anchors.right: parent.right + anchors.rightMargin: UM.Theme.getSize("default_margin").width + + spacing: 0 + + PackageTypeTab + { + width: implicitWidth + text: catalog.i18nc("@button", "Plug-ins") + onClicked: content.source = "Plugins.qml" + } + PackageTypeTab + { + width: implicitWidth + text: catalog.i18nc("@button", "Materials") + onClicked: content.source = "Materials.qml" + } + } + } + Rectangle //Page contents. { Layout.preferredWidth: parent.width diff --git a/plugins/Marketplace/resources/qml/Materials.qml b/plugins/Marketplace/resources/qml/Materials.qml new file mode 100644 index 0000000000..4f3c59d9fb --- /dev/null +++ b/plugins/Marketplace/resources/qml/Materials.qml @@ -0,0 +1,12 @@ +// Copyright (c) 2021 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import Marketplace 1.0 as Marketplace + +Packages +{ + model: Marketplace.PackageList + { + packageTypeFilter: "material" + } +} \ No newline at end of file diff --git a/plugins/Marketplace/resources/qml/PackageTypeTab.qml b/plugins/Marketplace/resources/qml/PackageTypeTab.qml new file mode 100644 index 0000000000..9b6136f1f0 --- /dev/null +++ b/plugins/Marketplace/resources/qml/PackageTypeTab.qml @@ -0,0 +1,26 @@ +// 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 UM 1.0 as UM + +TabButton +{ + background: Rectangle + { + anchors.fill: parent + color: parent.checked ? UM.Theme.getColor("main_background") : UM.Theme.getColor("detail_background") + border.color: UM.Theme.getColor("detail_background") + border.width: UM.Theme.getSize("thick_lining").width + } + + contentItem: Label + { + text: parent.text + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + width: contentWidth + anchors.centerIn: parent + } +} \ No newline at end of file diff --git a/plugins/Marketplace/resources/qml/Packages.qml b/plugins/Marketplace/resources/qml/Packages.qml new file mode 100644 index 0000000000..5442247668 --- /dev/null +++ b/plugins/Marketplace/resources/qml/Packages.qml @@ -0,0 +1,175 @@ +// 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 UM 1.4 as UM + +ScrollView +{ + id: packages + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + property alias model: packagesListview.model + + Component.onCompleted: model.request() + Component.onDestruction: model.abortRequest() + + ListView + { + id: packagesListview + width: parent.width + + spacing: UM.Theme.getSize("default_margin").height + + delegate: Rectangle + { + width: packagesListview.width + height: UM.Theme.getSize("card").height + + color: UM.Theme.getColor("main_background") + radius: UM.Theme.getSize("default_radius").width + + Label + { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Math.round((parent.height - height) / 2) + + text: model.package.displayName + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("text") + } + } + + footer: Item //Wrapper item to add spacing between content and footer. + { + width: parent.width + height: UM.Theme.getSize("card").height + packagesListview.spacing + Button + { + id: loadMoreButton + width: parent.width + height: UM.Theme.getSize("card").height + 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. + + background: Rectangle + { + anchors.fill: parent + radius: UM.Theme.getSize("default_radius").width + color: UM.Theme.getColor("main_background") + } + + Row + { + anchors.centerIn: parent + + spacing: UM.Theme.getSize("thin_margin").width + + states: + [ + State + { + name: "Error" + when: packages.model.errorMessage != "" + PropertyChanges + { + target: errorIcon + visible: true + } + PropertyChanges + { + target: loadMoreIcon + visible: false + } + PropertyChanges + { + target: loadMoreLabel + text: catalog.i18nc("@button", "Failed to load packages:") + " " + packages.model.errorMessage + "\n" + catalog.i18nc("@button", "Retry?") + } + }, + State + { + name: "Loading" + when: packages.model.isLoading + PropertyChanges + { + target: loadMoreIcon + source: UM.Theme.getIcon("ArrowDoubleCircleRight") + color: UM.Theme.getColor("action_button_disabled_text") + } + PropertyChanges + { + target: loadMoreLabel + text: catalog.i18nc("@button", "Loading") + color: UM.Theme.getColor("action_button_disabled_text") + } + }, + State + { + name: "LastPage" + when: !packages.model.hasMore + PropertyChanges + { + target: loadMoreIcon + visible: false + } + PropertyChanges + { + target: loadMoreLabel + text: catalog.i18nc("@button", "No more results to load") + color: UM.Theme.getColor("action_button_disabled_text") + } + } + ] + + Item + { + width: (errorIcon.visible || loadMoreIcon.visible) ? UM.Theme.getSize("small_button_icon").width : 0 + height: UM.Theme.getSize("small_button_icon").height + anchors.verticalCenter: loadMoreLabel.verticalCenter + + UM.StatusIcon + { + id: errorIcon + anchors.fill: parent + + status: UM.StatusIcon.Status.ERROR + visible: false + } + UM.RecolorImage + { + id: loadMoreIcon + anchors.fill: parent + + source: UM.Theme.getIcon("ArrowDown") + color: UM.Theme.getColor("secondary_button_text") + + RotationAnimator + { + target: loadMoreIcon + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + running: packages.model.isLoading + alwaysRunToEnd: true + } + } + } + Label + { + id: loadMoreLabel + text: catalog.i18nc("@button", "Load more") + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("secondary_button_text") + } + } + } + } + } +} diff --git a/plugins/Marketplace/resources/qml/Plugins.qml b/plugins/Marketplace/resources/qml/Plugins.qml index 0fbe8b7734..71814f54ad 100644 --- a/plugins/Marketplace/resources/qml/Plugins.qml +++ b/plugins/Marketplace/resources/qml/Plugins.qml @@ -1,174 +1,12 @@ // 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 Marketplace 1.0 as Marketplace -import UM 1.4 as UM -ScrollView +Packages { - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - - ListView + model: Marketplace.PackageList { - id: pluginColumn - width: parent.width - - model: Marketplace.PackageList - { - id: pluginList - } - spacing: UM.Theme.getSize("default_margin").height - - delegate: Rectangle - { - width: pluginColumn.width - height: UM.Theme.getSize("card").height - - color: UM.Theme.getColor("main_background") - radius: UM.Theme.getSize("default_radius").width - - Label - { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: Math.round((parent.height - height) / 2) - - text: model.package.displayName - font: UM.Theme.getFont("medium_bold") - color: UM.Theme.getColor("text") - } - } - - footer: Item //Wrapper item to add spacing between content and footer. - { - width: parent.width - height: UM.Theme.getSize("card").height + pluginColumn.spacing - Button - { - id: loadMoreButton - width: parent.width - height: UM.Theme.getSize("card").height - anchors.bottom: parent.bottom - - enabled: pluginList.hasMore && !pluginList.isLoading || pluginList.errorMessage != "" - onClicked: pluginList.request() //Load next page in plug-in list. - - background: Rectangle - { - anchors.fill: parent - radius: UM.Theme.getSize("default_radius").width - color: UM.Theme.getColor("main_background") - } - - Row - { - anchors.centerIn: parent - - spacing: UM.Theme.getSize("thin_margin").width - - states: - [ - State - { - name: "Error" - when: pluginList.errorMessage != "" - PropertyChanges - { - target: errorIcon - visible: true - } - PropertyChanges - { - target: loadMoreIcon - visible: false - } - PropertyChanges - { - target: loadMoreLabel - text: catalog.i18nc("@button", "Failed to load plug-ins:") + " " + pluginList.errorMessage + "\n" + catalog.i18nc("@button", "Retry?") - } - }, - State - { - name: "Loading" - when: pluginList.isLoading - PropertyChanges - { - target: loadMoreIcon - source: UM.Theme.getIcon("ArrowDoubleCircleRight") - color: UM.Theme.getColor("action_button_disabled_text") - } - PropertyChanges - { - target: loadMoreLabel - text: catalog.i18nc("@button", "Loading") - color: UM.Theme.getColor("action_button_disabled_text") - } - }, - State - { - name: "LastPage" - when: !pluginList.hasMore - PropertyChanges - { - target: loadMoreIcon - visible: false - } - PropertyChanges - { - target: loadMoreLabel - text: catalog.i18nc("@button", "No more results to load") - color: UM.Theme.getColor("action_button_disabled_text") - } - } - ] - - Item - { - width: (errorIcon.visible || loadMoreIcon.visible) ? UM.Theme.getSize("small_button_icon").width : 0 - height: UM.Theme.getSize("small_button_icon").height - anchors.verticalCenter: loadMoreLabel.verticalCenter - - UM.StatusIcon - { - id: errorIcon - anchors.fill: parent - - status: UM.StatusIcon.Status.ERROR - visible: false - } - UM.RecolorImage - { - id: loadMoreIcon - anchors.fill: parent - - source: UM.Theme.getIcon("ArrowDown") - color: UM.Theme.getColor("secondary_button_text") - - RotationAnimator - { - target: loadMoreIcon - from: 0 - to: 360 - duration: 1000 - loops: Animation.Infinite - running: pluginList.isLoading - alwaysRunToEnd: true - } - } - } - Label - { - id: loadMoreLabel - text: catalog.i18nc("@button", "Load more") - font: UM.Theme.getFont("medium_bold") - color: UM.Theme.getColor("secondary_button_text") - } - } - } - } + packageTypeFilter: "plugin" } -} +} \ No newline at end of file