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 @@ + + +