From 86d5d315bc3d5974be279061c1667f4bbf02caaa Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Mon, 1 Nov 2021 17:02:07 +0100 Subject: [PATCH 01/21] 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 @@ + + + From b53a9840f3138893dec2e6c562aa6e4167cb0f97 Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Mon, 1 Nov 2021 17:02:07 +0100 Subject: [PATCH 02/21] Moved ManagePackagesButton to its own file For better readability Contributes to CURA-8558 --- .../resources/qml/ManagePackagesButton.qml | 44 +++++++++++++++++++ .../Marketplace/resources/qml/Marketplace.qml | 34 +------------- 2 files changed, 45 insertions(+), 33 deletions(-) create mode 100644 plugins/Marketplace/resources/qml/ManagePackagesButton.qml diff --git a/plugins/Marketplace/resources/qml/ManagePackagesButton.qml b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml new file mode 100644 index 0000000000..e6c1406858 --- /dev/null +++ b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml @@ -0,0 +1,44 @@ +// Copyright (c) 2021 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import UM 1.2 as UM +import Cura 1.6 as Cura + +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +Button +{ + id: root + width: childrenRect.width + height: childrenRect.height + + hoverEnabled: true + + background: Rectangle + { + color: UM.Theme.getColor("action_button") + border.color: "transparent" + border.width: UM.Theme.getSize("default_lining").width + } + + Cura.ToolTip + { + id: tooltip + + tooltipText: catalog.i18nc("@info:tooltip", "Manage packages") + arrowSize: 0 + visible: root.hovered + } + + UM.RecolorImage + { + id: icon + + width: UM.Theme.getSize("section_icon").width + height: UM.Theme.getSize("section_icon").height + + color: UM.Theme.getColor("icon") + source: UM.Theme.getIcon("Settings") + } +} diff --git a/plugins/Marketplace/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml index 7004b69d46..bbe5b2b9e9 100644 --- a/plugins/Marketplace/resources/qml/Marketplace.qml +++ b/plugins/Marketplace/resources/qml/Marketplace.qml @@ -75,45 +75,13 @@ Window Layout.preferredWidth: parent.width Layout.preferredHeight: childrenRect.height - Button + ManagePackagesButton { 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" From 397baebda45e44fc7d1e182394c514a56b3703ea Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Tue, 2 Nov 2021 08:54:57 +0100 Subject: [PATCH 03/21] Changed deprecated qml syntax Contributes to CURA-8558 --- plugins/Marketplace/resources/qml/Marketplace.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Marketplace/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml index bbe5b2b9e9..3adedd9cf0 100644 --- a/plugins/Marketplace/resources/qml/Marketplace.qml +++ b/plugins/Marketplace/resources/qml/Marketplace.qml @@ -130,7 +130,7 @@ Window Connections { target: content - onLoaded: function() + function onLoaded() { pageTitle.text = content.item.pageTitle } From c4c99f665726cb0f25444cfacaa0c62ac0b605f8 Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Tue, 2 Nov 2021 14:31:12 +0100 Subject: [PATCH 04/21] Added sections to the packagelists By providing a `section_title` with a string to the `package_data` packages can be subdivided in sections, each with its own header. For remote packages this will be `None` and therefore no sections are created there. Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 5 +++- plugins/Marketplace/PackageModel.py | 7 +++++- .../Marketplace/resources/qml/Packages.qml | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index ff221ed606..f71c5e4f06 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -41,8 +41,11 @@ class LocalPackageList(PackageList): bundled = plugin_registry.getInstalledPlugins() for b in bundled: - package = PackageModel({"package_id": b, "display_name": b}, parent = self) + package = PackageModel({"package_id": b, "display_name": b, "section_title": "bundled"}, parent = self) self.appendItem({"package": package}) packages = package_manager.getInstalledPackageIDs() + for p in packages: + package = PackageModel({"package_id": p, "display_name": p, "section_title": "package"}, parent = self) + self.appendItem({"package": package}) self.setIsLoading(False) self.setHasMore(False) diff --git a/plugins/Marketplace/PackageModel.py b/plugins/Marketplace/PackageModel.py index 5ca25b370b..0ade18839e 100644 --- a/plugins/Marketplace/PackageModel.py +++ b/plugins/Marketplace/PackageModel.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import pyqtProperty, QObject -from typing import Any, Dict +from typing import Any, Dict, Optional from UM.i18n import i18nCatalog # To translate placeholder names if data is not present. @@ -25,6 +25,7 @@ class PackageModel(QObject): super().__init__(parent) self._package_id = package_data.get("package_id", "UnknownPackageId") self._display_name = package_data.get("display_name", catalog.i18nc("@label:property", "Unknown Package")) + self._section_title = package_data.get("section_title", None) @pyqtProperty(str, constant = True) def packageId(self) -> str: @@ -33,3 +34,7 @@ class PackageModel(QObject): @pyqtProperty(str, constant = True) def displayName(self) -> str: return self._display_name + + @pyqtProperty(str, constant = True) + def sectionTitle(self) -> Optional[str]: + return self._section_title diff --git a/plugins/Marketplace/resources/qml/Packages.qml b/plugins/Marketplace/resources/qml/Packages.qml index cdc066626c..e5414e3f67 100644 --- a/plugins/Marketplace/resources/qml/Packages.qml +++ b/plugins/Marketplace/resources/qml/Packages.qml @@ -24,6 +24,29 @@ ScrollView spacing: UM.Theme.getSize("default_margin").height + section.property: "package.sectionTitle" + section.criteria: ViewSection.FullString + section.delegate: Rectangle + { + width: packagesListview.width + height: sectionHeaderText.implicitHeight + UM.Theme.getSize("default_margin").height + + color: UM.Theme.getColor("detail_background") + + required property string section + + Label + { + id: sectionHeaderText + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + text: parent.section + font: UM.Theme.getFont("large") + color: UM.Theme.getColor("text") + } + } + delegate: Rectangle { width: packagesListview.width From 3f700e5d0c81bd68dfe94030c880f3b1dbfaea54 Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Tue, 2 Nov 2021 14:33:53 +0100 Subject: [PATCH 05/21] Only show Footer when the packagelist is paginated It doesn't make sense to show a footer when items are retrieved in one go. Except when an error occurs. Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 2 ++ plugins/Marketplace/PackageList.py | 5 +++++ plugins/Marketplace/resources/qml/Packages.qml | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index f71c5e4f06..44eaac9a0d 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -5,6 +5,7 @@ from PyQt5.QtCore import pyqtSlot, Qt from typing import TYPE_CHECKING from UM.i18n import i18nCatalog +from UM.Logger import Logger from cura.CuraApplication import CuraApplication @@ -23,6 +24,7 @@ class LocalPackageList(PackageList): def __init__(self, parent: "QObject" = None) -> None: super().__init__(parent) self._application = CuraApplication.getInstance() + self._has_footer = False @pyqtSlot() def updatePackages(self) -> None: diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py index 00532313f0..de07e5e2fb 100644 --- a/plugins/Marketplace/PackageList.py +++ b/plugins/Marketplace/PackageList.py @@ -22,6 +22,7 @@ class PackageList(ListModel): self.addRoleName(self.PackageRole, "package") self._is_loading = False self._has_more = False + self._has_footer = True @pyqtSlot() def updatePackages(self) -> None: @@ -85,3 +86,7 @@ class PackageList(ListModel): :return: An error message, if any, or an empty string if everything went okay. """ return self._error_message + + @pyqtProperty(bool, constant = True) + def hasFooter(self) -> bool: + return self._has_footer diff --git a/plugins/Marketplace/resources/qml/Packages.qml b/plugins/Marketplace/resources/qml/Packages.qml index e5414e3f67..8ab241e784 100644 --- a/plugins/Marketplace/resources/qml/Packages.qml +++ b/plugins/Marketplace/resources/qml/Packages.qml @@ -71,7 +71,8 @@ ScrollView footer: Item { width: parent.width - height: UM.Theme.getSize("card").height + packagesListview.spacing + height: model.hasFooter || packages.model.errorMessage != "" ? UM.Theme.getSize("card").height + packagesListview.spacing : 0 + visible: model.hasFooter || packages.model.errorMessage != "" Button { id: loadMoreButton From 1b7b0b9caf5b5ce26e8b94e72b234bfdefdb7dbe Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Tue, 2 Nov 2021 16:20:25 +0100 Subject: [PATCH 06/21] Sort the different sections and packages The order in which UX defined the different sections are: - Installed Cura Plugins - Installed Materials - Bundled Plugins - Bundled Materials All packages need to be order at least by section, but I also took the liberty to sort the packages in these sections by Alphabet. Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 45 +++++++++++++++++++------ 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index 44eaac9a0d..cf81cd5dde 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -20,6 +20,18 @@ catalog = i18nCatalog("cura") class LocalPackageList(PackageList): PackageRole = Qt.UserRole + 1 + PACKAGE_SECTION_HEADER = { + "installed": + { + "plugin": catalog.i18nc("@label:property", "Installed Cura Plugins"), + "material": catalog.i18nc("@label:property", "Installed Materials") + }, + "bundled": + { + "plugin": catalog.i18nc("@label:property", "Bundled Plugins"), + "material": catalog.i18nc("@label:property", "Bundled Materials") + } + } def __init__(self, parent: "QObject" = None) -> None: super().__init__(parent) @@ -38,16 +50,29 @@ class LocalPackageList(PackageList): self._getLocalPackages() def _getLocalPackages(self) -> None: - plugin_registry = self._application.getPluginRegistry() - package_manager = self._application.getPackageManager() + sorted_sections = {} + for section in self._getSections(): + packages = filter(lambda p: p["section_title"] == section, self._allPackageInfo()) + sorted_sections[section] = sorted(packages, key = lambda p: p["display_name"]) + + for section in sorted_sections.values(): + for package_data in section: + package = PackageModel(package_data, parent = self) + self.appendItem({"package": package}) - bundled = plugin_registry.getInstalledPlugins() - for b in bundled: - package = PackageModel({"package_id": b, "display_name": b, "section_title": "bundled"}, parent = self) - self.appendItem({"package": package}) - packages = package_manager.getInstalledPackageIDs() - for p in packages: - package = PackageModel({"package_id": p, "display_name": p, "section_title": "package"}, parent = self) - self.appendItem({"package": package}) self.setIsLoading(False) self.setHasMore(False) + + def _getSections(self): + for package_type in self.PACKAGE_SECTION_HEADER.values(): + for section in package_type.values(): + yield section + + def _allPackageInfo(self): + manager = self._application.getPackageManager() + for package_id in manager.getAllInstalledPackageIDs(): + package_data = manager.getInstalledPackageInfo(package_id) + bundled_or_installed = "bundled" if package_data["is_bundled"] else "installed" + package_type = package_data["package_type"] + package_data["section_title"] = self.PACKAGE_SECTION_HEADER[bundled_or_installed][package_type] + yield package_data From 5db6e50dee2cebd2d31f4b4e9aa1eb7f73c9f4c2 Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Tue, 2 Nov 2021 16:21:02 +0100 Subject: [PATCH 07/21] Fixed typo not plug-ins but plugins Contributes to CURA-8558 --- plugins/Marketplace/resources/qml/Marketplace.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Marketplace/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml index 3adedd9cf0..2d95904257 100644 --- a/plugins/Marketplace/resources/qml/Marketplace.qml +++ b/plugins/Marketplace/resources/qml/Marketplace.qml @@ -100,7 +100,7 @@ Window PackageTypeTab { width: implicitWidth - text: catalog.i18nc("@button", "Plug-ins") + text: catalog.i18nc("@button", "Plugins") onClicked: content.source = "Plugins.qml" } PackageTypeTab From 8fad2e0f39edb59d2e2f7fba11def1787af08c8a Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Wed, 3 Nov 2021 10:33:00 +0100 Subject: [PATCH 08/21] Changed section header for Installed Plugins As agreed with UX changed: `Installed Cura Plugins` to `Installed Plugins` Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index cf81cd5dde..171751c238 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -23,7 +23,7 @@ class LocalPackageList(PackageList): PACKAGE_SECTION_HEADER = { "installed": { - "plugin": catalog.i18nc("@label:property", "Installed Cura Plugins"), + "plugin": catalog.i18nc("@label:property", "Installed Plugins"), "material": catalog.i18nc("@label:property", "Installed Materials") }, "bundled": From 080e3b9f27610aa2ea6147be9b0f423d9bb4a144 Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Wed, 3 Nov 2021 12:04:10 +0100 Subject: [PATCH 09/21] To be removed packages are still listed for the current session A user might still need to interact with a **to be removed** package and it is also still being used in the current Cura session. But the current package list doesn't list that package anymore. Introduced a `getPackagesToRemove()` method in the Uranium PackageManager to circumvent this issue. Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index 171751c238..589ba26226 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -70,9 +70,11 @@ class LocalPackageList(PackageList): def _allPackageInfo(self): manager = self._application.getPackageManager() - for package_id in manager.getAllInstalledPackageIDs(): - package_data = manager.getInstalledPackageInfo(package_id) - bundled_or_installed = "bundled" if package_data["is_bundled"] else "installed" - package_type = package_data["package_type"] - package_data["section_title"] = self.PACKAGE_SECTION_HEADER[bundled_or_installed][package_type] - yield package_data + for package_type, packages in manager.getAllInstalledPackagesInfo().items(): + for package_data in packages: + bundled_or_installed = "installed" if manager.isUserInstalledPackage(package_data["package_id"]) else "bundled" + package_data["section_title"] = self.PACKAGE_SECTION_HEADER[bundled_or_installed][package_type] + yield package_data + + for package_data in manager.getPackagesToRemove().values(): + yield package_data["package_info"] From 02187035925110c26970eb0241de2f7acb746dd1 Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Wed, 3 Nov 2021 12:15:56 +0100 Subject: [PATCH 10/21] To be installed packages are still listed for the current session A user might still need to interact with a **to be installed** package. But the current package list doesn't list that package anymore. Introduced a `getPackagesToInstall()` method in the Uranium PackageManager to circumvent this issue. Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index 589ba26226..e672c191f6 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -78,3 +78,9 @@ class LocalPackageList(PackageList): for package_data in manager.getPackagesToRemove().values(): yield package_data["package_info"] + + for package_data in manager.getPackagesToInstall().values(): + package_info = package_data["package_info"] + package_type = package_info["package_type"] + package_info["section_title"] = self.PACKAGE_SECTION_HEADER["installed"][package_type] + yield package_info From edc71f12a323b81c6caf34623d84d175a815a275 Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Wed, 3 Nov 2021 13:43:08 +0100 Subject: [PATCH 11/21] Updated documentation and typing Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 35 ++++++++++++++++------- plugins/Marketplace/Marketplace.py | 2 +- plugins/Marketplace/PackageList.py | 38 +++++++++++++------------ 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index e672c191f6..aad1bbbaa1 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -2,10 +2,9 @@ # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import pyqtSlot, Qt -from typing import TYPE_CHECKING +from typing import Any, Dict, Generator, TYPE_CHECKING from UM.i18n import i18nCatalog -from UM.Logger import Logger from cura.CuraApplication import CuraApplication @@ -31,7 +30,7 @@ class LocalPackageList(PackageList): "plugin": catalog.i18nc("@label:property", "Bundled Plugins"), "material": catalog.i18nc("@label:property", "Bundled Materials") } - } + } # The section headers to be used for the different package categories def __init__(self, parent: "QObject" = None) -> None: super().__init__(parent) @@ -40,42 +39,58 @@ class LocalPackageList(PackageList): @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. + """Update the list with local packages, these are materials or plugin, either bundled or user installed. The list + will also contain **to be removed** or **to be installed** packages since the user might still want to interact + with these. """ self.setErrorMessage("") # Clear any previous errors. self.setIsLoading(True) self._getLocalPackages() + self.setIsLoading(True) def _getLocalPackages(self) -> None: + """ Obtain the local packages. + The list is sorted per category as in the order of the PACKAGE_SECTION_HEADER dictionary, whereas the packages + for the sections are sorted alphabetically on the display name + """ + sorted_sections = {} + # Filter the package list per section_title and sort these for section in self._getSections(): packages = filter(lambda p: p["section_title"] == section, self._allPackageInfo()) sorted_sections[section] = sorted(packages, key = lambda p: p["display_name"]) + # Create a PackageModel from the sorted package_info and append them to the list for section in sorted_sections.values(): for package_data in section: package = PackageModel(package_data, parent = self) self.appendItem({"package": package}) self.setIsLoading(False) - self.setHasMore(False) + self.setHasMore(False) # All packages should have been loaded at this time - def _getSections(self): + def _getSections(self) -> Generator[str]: + """ Flatten and order the PACKAGE_SECTION_HEADER such that it can be used in obtaining the packages in the + correct order""" for package_type in self.PACKAGE_SECTION_HEADER.values(): for section in package_type.values(): yield section - def _allPackageInfo(self): + def _allPackageInfo(self) -> Generator[Dict[str, Any]]: + """ A generator which returns a unordered list of package_info, the section_title is appended to the each + package_info""" + manager = self._application.getPackageManager() + + # Get all the installed packages, add a section_title depending on package_type and user installed for package_type, packages in manager.getAllInstalledPackagesInfo().items(): for package_data in packages: bundled_or_installed = "installed" if manager.isUserInstalledPackage(package_data["package_id"]) else "bundled" package_data["section_title"] = self.PACKAGE_SECTION_HEADER[bundled_or_installed][package_type] yield package_data + # Get all to be removed package_info's. These packages are still used in the current session so the user might + # to interact with these in the list for package_data in manager.getPackagesToRemove().values(): yield package_data["package_info"] diff --git a/plugins/Marketplace/Marketplace.py b/plugins/Marketplace/Marketplace.py index 9418174d19..18d80d6e68 100644 --- a/plugins/Marketplace/Marketplace.py +++ b/plugins/Marketplace/Marketplace.py @@ -9,8 +9,8 @@ from typing import Optional, TYPE_CHECKING from cura.ApplicationMetadata import CuraSDKVersion from cura.CuraApplication import CuraApplication # Creating QML objects and managing packages. from cura.UltimakerCloud import UltimakerCloudConstants + from UM.Extension import Extension # We are implementing the main object of an extension here. -from UM.Logger import Logger from UM.PluginRegistry import PluginRegistry # To find out where we are stored (the proper way). from .RemotePackageList import RemotePackageList # To register this type with QML. diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py index de07e5e2fb..17a755255b 100644 --- a/plugins/Marketplace/PackageList.py +++ b/plugins/Marketplace/PackageList.py @@ -14,6 +14,9 @@ catalog = i18nCatalog("cura") class PackageList(ListModel): + """ A List model for Packages, this class serves as parent class for more detailed implementations. + such as Packages obtained from Remote or Local source + """ PackageRole = Qt.UserRole + 1 def __init__(self, parent: "QObject" = None) -> None: @@ -26,20 +29,20 @@ class PackageList(ListModel): @pyqtSlot() def updatePackages(self) -> None: - """ - Initialize the first page of packages - """ - self.setErrorMessage("") # Clear any previous errors. - self.isLoadingChanged.emit() + """ A Qt slot which will update the List from a source. Actual implementation should be done in the child class""" + pass @pyqtSlot() def abortUpdating(self) -> None: + """ A Qt slot which allows the update process to be aborted. Override this for child classes with async/callback + updatePackges methods""" pass def reset(self) -> None: + """ Resets and clears the list""" self.clear() - isLoadingChanged = pyqtSignal() + isLoadingChanged = pyqtSignal() # The signal for isLoading property def setIsLoading(self, value: bool) -> None: if self._is_loading != value: @@ -48,13 +51,12 @@ class PackageList(ListModel): @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 being gathered, or ``False`` if . + """ Indicating if the the packages are loading + :return" ``True`` if the list is being obtained, otherwise ``False`` """ return self._is_loading - hasMoreChanged = pyqtSignal() + hasMoreChanged = pyqtSignal() # The signal for hasMore property def setHasMore(self, value: bool) -> None: if self._has_more != value: @@ -63,24 +65,21 @@ class PackageList(ListModel): @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. + """ Indicating if there are more packages available to load. + :return: ``True`` if there are more packages to load, or ``False``. """ return self._has_more + errorMessageChanged = pyqtSignal() # The signal for errorMessage property + def setErrorMessage(self, error_message: str) -> None: if self._error_message != error_message: self._error_message = error_message self.errorMessageChanged.emit() - errorMessageChanged = pyqtSignal() - @pyqtProperty(str, notify = errorMessageChanged, fset = setErrorMessage) def errorMessage(self) -> str: - """ - If an error occurred getting the list of packages, an error message will be held here. + """ If an error occurred getting the list of packages, an error message will be held here. If no error occurred (yet), this will be an empty string. :return: An error message, if any, or an empty string if everything went okay. @@ -89,4 +88,7 @@ class PackageList(ListModel): @pyqtProperty(bool, constant = True) def hasFooter(self) -> bool: + """ Indicating if the PackageList should have a Footer visible. For paginated PackageLists + :return: ``True`` if a Footer should be displayed in the ListView, e.q.: paginated lists, ``False`` Otherwise + """ return self._has_footer From f9f43b79b05525fe7028982cabd224fee2dde4c1 Mon Sep 17 00:00:00 2001 From: "j.spijker@ultimaker.com" Date: Wed, 3 Nov 2021 14:11:07 +0100 Subject: [PATCH 12/21] Don't pollute the package_info with section_title The previous implementation added a section_title to the package_info which was also stored in the packages.json. The section_title is now provided to the PackageModel as an extra optional argument. Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 56 ++++++++++++------------- plugins/Marketplace/PackageModel.py | 5 ++- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index aad1bbbaa1..121ac72308 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -1,8 +1,8 @@ # 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 Any, Dict, Generator, TYPE_CHECKING +from PyQt5.QtCore import pyqtSlot, Qt from UM.i18n import i18nCatalog @@ -30,11 +30,11 @@ class LocalPackageList(PackageList): "plugin": catalog.i18nc("@label:property", "Bundled Plugins"), "material": catalog.i18nc("@label:property", "Bundled Materials") } - } # The section headers to be used for the different package categories + } # The section headers to be used for the different package categories def __init__(self, parent: "QObject" = None) -> None: super().__init__(parent) - self._application = CuraApplication.getInstance() + self._manager = CuraApplication.getInstance().getPackageManager() self._has_footer = False @pyqtSlot() @@ -50,52 +50,52 @@ class LocalPackageList(PackageList): def _getLocalPackages(self) -> None: """ Obtain the local packages. + The list is sorted per category as in the order of the PACKAGE_SECTION_HEADER dictionary, whereas the packages for the sections are sorted alphabetically on the display name """ sorted_sections = {} - # Filter the package list per section_title and sort these + # Filter the packages per section title and sort these alphabetically for section in self._getSections(): - packages = filter(lambda p: p["section_title"] == section, self._allPackageInfo()) - sorted_sections[section] = sorted(packages, key = lambda p: p["display_name"]) + packages = filter(lambda p: p.sectionTitle == section, self._allPackageInfo()) + sorted_sections[section] = sorted(packages, key = lambda p: p.displayName) - # Create a PackageModel from the sorted package_info and append them to the list + # Append the order PackageModels to the list for section in sorted_sections.values(): for package_data in section: - package = PackageModel(package_data, parent = self) - self.appendItem({"package": package}) + self.appendItem({"package": package_data}) self.setIsLoading(False) self.setHasMore(False) # All packages should have been loaded at this time - def _getSections(self) -> Generator[str]: + def _getSections(self) -> Generator[str, None, None]: """ Flatten and order the PACKAGE_SECTION_HEADER such that it can be used in obtaining the packages in the correct order""" for package_type in self.PACKAGE_SECTION_HEADER.values(): for section in package_type.values(): yield section - def _allPackageInfo(self) -> Generator[Dict[str, Any]]: - """ A generator which returns a unordered list of package_info, the section_title is appended to the each - package_info""" - - manager = self._application.getPackageManager() + def _allPackageInfo(self) -> Generator[PackageModel, None, None]: + """ A generator which returns a unordered list of all the PackageModels""" # Get all the installed packages, add a section_title depending on package_type and user installed - for package_type, packages in manager.getAllInstalledPackagesInfo().items(): - for package_data in packages: - bundled_or_installed = "installed" if manager.isUserInstalledPackage(package_data["package_id"]) else "bundled" - package_data["section_title"] = self.PACKAGE_SECTION_HEADER[bundled_or_installed][package_type] - yield package_data + for packages in self._manager.getAllInstalledPackagesInfo().values(): + for package_info in packages: + yield self._makePackageModel(package_info) # Get all to be removed package_info's. These packages are still used in the current session so the user might - # to interact with these in the list - for package_data in manager.getPackagesToRemove().values(): - yield package_data["package_info"] + # still want to interact with these. + for package_data in self._manager.getPackagesToRemove().values(): + yield self._makePackageModel(package_data["package_info"]) - for package_data in manager.getPackagesToInstall().values(): - package_info = package_data["package_info"] - package_type = package_info["package_type"] - package_info["section_title"] = self.PACKAGE_SECTION_HEADER["installed"][package_type] - yield package_info + # Get all to be installed package_info's. Since the user might want to interact with these + for package_data in self._manager.getPackagesToInstall().values(): + yield self._makePackageModel(package_data["package_info"]) + + def _makePackageModel(self, package_info: Dict[str, Any]) -> PackageModel: + """ Create a PackageModel from the package_info and determine its section_title""" + bundled_or_installed = "installed" if self._manager.isUserInstalledPackage(package_info["package_id"]) else "bundled" + package_type = package_info["package_type"] + section_title = self.PACKAGE_SECTION_HEADER[bundled_or_installed][package_type] + return PackageModel(package_info, section_title = section_title, parent = self) diff --git a/plugins/Marketplace/PackageModel.py b/plugins/Marketplace/PackageModel.py index 0ade18839e..e57a402fd6 100644 --- a/plugins/Marketplace/PackageModel.py +++ b/plugins/Marketplace/PackageModel.py @@ -16,16 +16,17 @@ class PackageModel(QObject): QML. The model can also be constructed directly from a response received by the API. """ - def __init__(self, package_data: Dict[str, Any], parent: QObject = None) -> None: + def __init__(self, package_data: Dict[str, Any], section_title: Optional[str] = None, parent: Optional[QObject] = None) -> None: """ Constructs a new model for a single package. :param package_data: The data received from the Marketplace API about the package to create. + :param section_title: If the packages are to be categorized per section provide the section_title :param parent: The parent QML object that controls the lifetime of this model (normally a PackageList). """ super().__init__(parent) self._package_id = package_data.get("package_id", "UnknownPackageId") self._display_name = package_data.get("display_name", catalog.i18nc("@label:property", "Unknown Package")) - self._section_title = package_data.get("section_title", None) + self._section_title = section_title @pyqtProperty(str, constant = True) def packageId(self) -> str: From 07fcf8b533d0b33968262d4f54b884bfbb259304 Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Wed, 3 Nov 2021 16:01:43 +0100 Subject: [PATCH 13/21] Fixed missing qoutes :face_palm: Contributes to CURA-8558 --- plugins/Marketplace/PackageList.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py index a2f3cf184c..bcdb02eb50 100644 --- a/plugins/Marketplace/PackageList.py +++ b/plugins/Marketplace/PackageList.py @@ -89,5 +89,5 @@ class PackageList(ListModel): @pyqtProperty(bool, constant = True) def hasFooter(self) -> bool: """ Indicating if the PackageList should have a Footer visible. For paginated PackageLists - :return: ``True`` if a Footer should be displayed in the ListView, e.q.: paginated lists, ``False`` Otherwise + :return: ``True`` if a Footer should be displayed in the ListView, e.q.: paginated lists, ``False`` Otherwise""" return self._has_footer From e7aecb6c06c278a1a5f29325d85a3fdaf4feba9c Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Wed, 3 Nov 2021 16:29:35 +0100 Subject: [PATCH 14/21] Fixed mypy typing failure @ghostkeeper being nerd snipped It's giving that typing failure because the section variable is re-used. First as elements from self._getSections (strs) and then as elements from sorted_sections.values() (List[PackageModel]s). Python has no variable scopes within functions so the variable still exists after the first for loop. Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index 121ac72308..e2a13b051c 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -62,8 +62,8 @@ class LocalPackageList(PackageList): sorted_sections[section] = sorted(packages, key = lambda p: p.displayName) # Append the order PackageModels to the list - for section in sorted_sections.values(): - for package_data in section: + for sorted_section in sorted_sections.values(): + for package_data in sorted_section: self.appendItem({"package": package_data}) self.setIsLoading(False) From 3a94fc0ced1e846f492a5a25069893d272e0fd9a Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Wed, 3 Nov 2021 17:58:16 +0100 Subject: [PATCH 15/21] Apply suggestions from code review Applied code review comments Co-authored-by: Jaime van Kessel --- plugins/Marketplace/LocalPackageList.py | 3 +-- plugins/Marketplace/PackageList.py | 2 +- plugins/Marketplace/resources/qml/Marketplace.qml | 2 +- plugins/Marketplace/resources/qml/Packages.qml | 3 +-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index e2a13b051c..fad5430082 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -18,7 +18,6 @@ catalog = i18nCatalog("cura") class LocalPackageList(PackageList): - PackageRole = Qt.UserRole + 1 PACKAGE_SECTION_HEADER = { "installed": { @@ -46,7 +45,7 @@ class LocalPackageList(PackageList): self.setErrorMessage("") # Clear any previous errors. self.setIsLoading(True) self._getLocalPackages() - self.setIsLoading(True) + self.setIsLoading(False) def _getLocalPackages(self) -> None: """ Obtain the local packages. diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py index bcdb02eb50..e3b490a834 100644 --- a/plugins/Marketplace/PackageList.py +++ b/plugins/Marketplace/PackageList.py @@ -42,7 +42,7 @@ class PackageList(ListModel): """ Resets and clears the list""" self.clear() - isLoadingChanged = pyqtSignal() # The signal for isLoading property + isLoadingChanged = pyqtSignal() def setIsLoading(self, value: bool) -> None: if self._is_loading != value: diff --git a/plugins/Marketplace/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml index 2d95904257..4f36de01d0 100644 --- a/plugins/Marketplace/resources/qml/Marketplace.qml +++ b/plugins/Marketplace/resources/qml/Marketplace.qml @@ -66,7 +66,7 @@ Window font: UM.Theme.getFont("large") color: UM.Theme.getColor("text") - text: "" + text: content.item ? content.item.pageTitle: catalog.i18nc("@title", "Loading...") } } diff --git a/plugins/Marketplace/resources/qml/Packages.qml b/plugins/Marketplace/resources/qml/Packages.qml index 8ab241e784..c9ddf88d16 100644 --- a/plugins/Marketplace/resources/qml/Packages.qml +++ b/plugins/Marketplace/resources/qml/Packages.qml @@ -25,11 +25,10 @@ ScrollView spacing: UM.Theme.getSize("default_margin").height section.property: "package.sectionTitle" - section.criteria: ViewSection.FullString section.delegate: Rectangle { width: packagesListview.width - height: sectionHeaderText.implicitHeight + UM.Theme.getSize("default_margin").height + height: sectionHeaderText.height + UM.Theme.getSize("default_margin").height color: UM.Theme.getColor("detail_background") From e01e47b8fa1841401a18535827cd26d7c3e4a09c Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Thu, 4 Nov 2021 08:11:50 +0100 Subject: [PATCH 16/21] Performance increase for obtaining LocalPackages The speed increase on the function when running Yappi `LocalPackageList.updatePackages` | original | now | |----------|------| | 14 ms | 4 ms | Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 29 ++++++++----------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index fad5430082..fd0e340c86 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -1,8 +1,8 @@ # Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Any, Dict, Generator, TYPE_CHECKING -from PyQt5.QtCore import pyqtSlot, Qt +from typing import Any, Dict, Generator, List, Optional, TYPE_CHECKING +from PyQt5.QtCore import pyqtSlot, QObject from UM.i18n import i18nCatalog @@ -11,9 +11,6 @@ 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") @@ -31,7 +28,7 @@ class LocalPackageList(PackageList): } } # The section headers to be used for the different package categories - def __init__(self, parent: "QObject" = None) -> None: + def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent) self._manager = CuraApplication.getInstance().getPackageManager() self._has_footer = False @@ -51,22 +48,14 @@ class LocalPackageList(PackageList): """ Obtain the local packages. The list is sorted per category as in the order of the PACKAGE_SECTION_HEADER dictionary, whereas the packages - for the sections are sorted alphabetically on the display name + for the sections are sorted alphabetically on the display name. These sorted sections are then added to the items """ - - sorted_sections = {} - # Filter the packages per section title and sort these alphabetically + package_info = list(self._allPackageInfo()) + sorted_sections: List[Dict[str, PackageModel]] = [] for section in self._getSections(): - packages = filter(lambda p: p.sectionTitle == section, self._allPackageInfo()) - sorted_sections[section] = sorted(packages, key = lambda p: p.displayName) - - # Append the order PackageModels to the list - for sorted_section in sorted_sections.values(): - for package_data in sorted_section: - self.appendItem({"package": package_data}) - - self.setIsLoading(False) - self.setHasMore(False) # All packages should have been loaded at this time + packages = filter(lambda p: p.sectionTitle == section, package_info) + sorted_sections.extend([{"package": p} for p in sorted(packages, key = lambda p: p.displayName)]) + self.setItems(sorted_sections) def _getSections(self) -> Generator[str, None, None]: """ Flatten and order the PACKAGE_SECTION_HEADER such that it can be used in obtaining the packages in the From 11b3b081988691b749e194648a2b3d2af867f280 Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Thu, 4 Nov 2021 08:19:05 +0100 Subject: [PATCH 17/21] Implemented code review suggestions Contributes to CURA-8558 --- plugins/Marketplace/LocalPackageList.py | 6 +++++- plugins/Marketplace/PackageList.py | 8 ++++---- plugins/Marketplace/RemotePackageList.py | 3 +-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index fd0e340c86..6acbaa8500 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -4,6 +4,9 @@ from typing import Any, Dict, Generator, List, Optional, TYPE_CHECKING from PyQt5.QtCore import pyqtSlot, QObject +if TYPE_CHECKING: + from PyQt5.QtCore import QObject + from UM.i18n import i18nCatalog from cura.CuraApplication import CuraApplication @@ -28,7 +31,7 @@ class LocalPackageList(PackageList): } } # The section headers to be used for the different package categories - def __init__(self, parent: Optional[QObject] = None) -> None: + def __init__(self, parent: Optional["QObject"] = None) -> None: super().__init__(parent) self._manager = CuraApplication.getInstance().getPackageManager() self._has_footer = False @@ -43,6 +46,7 @@ class LocalPackageList(PackageList): self.setIsLoading(True) self._getLocalPackages() self.setIsLoading(False) + self.setHasMore(False) # All packages should have been loaded at this time def _getLocalPackages(self) -> None: """ Obtain the local packages. diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py index e3b490a834..8171d168f2 100644 --- a/plugins/Marketplace/PackageList.py +++ b/plugins/Marketplace/PackageList.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from UM.i18n import i18nCatalog from UM.Qt.ListModel import ListModel @@ -19,7 +19,7 @@ class PackageList(ListModel): """ PackageRole = Qt.UserRole + 1 - def __init__(self, parent: "QObject" = None) -> None: + def __init__(self, parent: Optional["QObject"] = None) -> None: super().__init__(parent) self._error_message = "" self.addRoleName(self.PackageRole, "package") @@ -56,7 +56,7 @@ class PackageList(ListModel): """ return self._is_loading - hasMoreChanged = pyqtSignal() # The signal for hasMore property + hasMoreChanged = pyqtSignal() def setHasMore(self, value: bool) -> None: if self._has_more != value: @@ -70,7 +70,7 @@ class PackageList(ListModel): """ return self._has_more - errorMessageChanged = pyqtSignal() # The signal for errorMessage property + errorMessageChanged = pyqtSignal() def setErrorMessage(self, error_message: str) -> None: if self._error_message != error_message: diff --git a/plugins/Marketplace/RemotePackageList.py b/plugins/Marketplace/RemotePackageList.py index 3cfb11d6ba..7e8ee321ac 100644 --- a/plugins/Marketplace/RemotePackageList.py +++ b/plugins/Marketplace/RemotePackageList.py @@ -18,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") @@ -26,7 +25,7 @@ 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: + def __init__(self, parent: Optional["QObject"] = None) -> None: super().__init__(parent) self._ongoing_request: Optional[HttpRequestData] = None From a58891ce58ecf02631efbe32f8ec3ddf7f65ee55 Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Thu, 4 Nov 2021 08:22:58 +0100 Subject: [PATCH 18/21] Fixed the loading spinner not spinning at first construction Contributes to CURA-8558 --- plugins/Marketplace/RemotePackageList.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/Marketplace/RemotePackageList.py b/plugins/Marketplace/RemotePackageList.py index 7e8ee321ac..3a6a329a52 100644 --- a/plugins/Marketplace/RemotePackageList.py +++ b/plugins/Marketplace/RemotePackageList.py @@ -33,6 +33,7 @@ class RemotePackageList(PackageList): self._package_type_filter = "" self._request_url = self._initialRequestUrl() + self.isLoadingChanged.emit() def __del__(self) -> None: """ From cbf83e500d15429e9d6e5ae148cc7873cba98351 Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Thu, 4 Nov 2021 10:04:59 +0100 Subject: [PATCH 19/21] Changed behaviour of hoover over button Per request of UX Contributes to CURA-8558 --- plugins/Marketplace/resources/qml/ManagePackagesButton.qml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/Marketplace/resources/qml/ManagePackagesButton.qml b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml index e6c1406858..bd857510f3 100644 --- a/plugins/Marketplace/resources/qml/ManagePackagesButton.qml +++ b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml @@ -14,11 +14,13 @@ Button height: childrenRect.height hoverEnabled: true + property color borderColor: hovered ? UM.Theme.getColor("primary") : "transparent" + property color backgroundColor: hovered ? UM.Theme.getColor("action_button_hovered") : UM.Theme.getColor("action_button") background: Rectangle { - color: UM.Theme.getColor("action_button") - border.color: "transparent" + color: backgroundColor + border.color: borderColor border.width: UM.Theme.getSize("default_lining").width } From fd409215c4df80e46c9ca7a2c9f4d59b6ae6b814 Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Thu, 4 Nov 2021 10:05:35 +0100 Subject: [PATCH 20/21] Tooltip shows point Per UX request Contributes to CURA-8558 --- plugins/Marketplace/resources/qml/ManagePackagesButton.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/Marketplace/resources/qml/ManagePackagesButton.qml b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml index bd857510f3..4a734f45ba 100644 --- a/plugins/Marketplace/resources/qml/ManagePackagesButton.qml +++ b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml @@ -29,7 +29,6 @@ Button id: tooltip tooltipText: catalog.i18nc("@info:tooltip", "Manage packages") - arrowSize: 0 visible: root.hovered } From a0467cd66f665d8c0adb684d40c81b9464b02486 Mon Sep 17 00:00:00 2001 From: Jelle Spijker Date: Thu, 4 Nov 2021 10:39:00 +0100 Subject: [PATCH 21/21] Fixed hard crash when deconstructing RemotePackageList while parsing Contributes to CURA-8558 --- plugins/Marketplace/RemotePackageList.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/Marketplace/RemotePackageList.py b/plugins/Marketplace/RemotePackageList.py index 3a6a329a52..8fa75453c1 100644 --- a/plugins/Marketplace/RemotePackageList.py +++ b/plugins/Marketplace/RemotePackageList.py @@ -107,8 +107,14 @@ class RemotePackageList(PackageList): return for package_data in response_data["data"]: - package = PackageModel(package_data, parent = self) - self.appendItem({"package": package}) # Add it to this list model. + try: + package = PackageModel(package_data, parent = self) + self.appendItem({"package": package}) # Add it to this list model. + except RuntimeError: + # Setting the ownership of this object to not qml can still result in a RuntimeError. Which can occur when quickly toggling + # between de-/constructing RemotePackageLists. This try-except is here to prevent a hard crash when the wrapped C++ object + # was deleted when it was still parsing the response + return self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page. self._ongoing_request = None