diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py new file mode 100644 index 0000000000..6acbaa8500 --- /dev/null +++ b/plugins/Marketplace/LocalPackageList.py @@ -0,0 +1,93 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +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 + +from .PackageList import PackageList +from .PackageModel import PackageModel # The contents of this list. + +catalog = i18nCatalog("cura") + + +class LocalPackageList(PackageList): + PACKAGE_SECTION_HEADER = { + "installed": + { + "plugin": catalog.i18nc("@label:property", "Installed Plugins"), + "material": catalog.i18nc("@label:property", "Installed Materials") + }, + "bundled": + { + "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: Optional["QObject"] = None) -> None: + super().__init__(parent) + self._manager = CuraApplication.getInstance().getPackageManager() + self._has_footer = False + + @pyqtSlot() + def updatePackages(self) -> None: + """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(False) + self.setHasMore(False) # All packages should have been loaded at this time + + 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. These sorted sections are then added to the items + """ + package_info = list(self._allPackageInfo()) + sorted_sections: List[Dict[str, PackageModel]] = [] + for section in self._getSections(): + 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 + correct order""" + for package_type in self.PACKAGE_SECTION_HEADER.values(): + for section in package_type.values(): + yield section + + 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 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 + # still want to interact with these. + for package_data in self._manager.getPackagesToRemove().values(): + yield self._makePackageModel(package_data["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/Marketplace.py b/plugins/Marketplace/Marketplace.py index b3f573f792..18d80d6e68 100644 --- a/plugins/Marketplace/Marketplace.py +++ b/plugins/Marketplace/Marketplace.py @@ -9,11 +9,12 @@ 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 .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 4ebbe8d349..8171d168f2 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 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,164 +14,80 @@ 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 """ - 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: + def __init__(self, parent: Optional["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 + self._has_footer = True @pyqtSlot() - def request(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. - - http = HttpRequestManager.getInstance() - self._ongoing_request = http.get( - self._request_url, - scope = self._scope, - callback = self._parseResponse, - error_callback = self._onError - ) - self.isLoadingChanged.emit() + def updatePackages(self) -> None: + """ A Qt slot which will update the List from a source. Actual implementation should be done in the child class""" + pass @pyqtSlot() - def abortRequest(self) -> None: - HttpRequestManager.getInstance().abortRequest(self._ongoing_request) - self._ongoing_request = None + 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() - self._request_url = self._initialRequestUrl() - isLoadingChanged = pyqtSignal() + 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: + """ Indicating if the the packages are loading + :return" ``True`` if the list is being obtained, otherwise ``False`` """ - Gives whether the list is currently loading the first page or loading more pages. - :return: ``True`` if the list is downloading, or ``False`` if not downloading. - """ - return self._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: + """ Indicating if there are more packages available to load. + :return: ``True`` if there are more packages to load, or ``False``. """ - 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 != "" + return self._has_more - 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 + errorMessageChanged = pyqtSignal() 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. """ 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"]: - try: - package = PackageModel(package_data, parent = self) - self.appendItem({"package": package}) # Add it to this list model. - except RuntimeError: - # I've tried setting the ownership of this object to not qml, but unfortunately that didn't prevent - # the issue that 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.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() + @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 diff --git a/plugins/Marketplace/PackageModel.py b/plugins/Marketplace/PackageModel.py index 5ca25b370b..e57a402fd6 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. @@ -16,15 +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 = section_title @pyqtProperty(str, constant = True) def packageId(self) -> str: @@ -33,3 +35,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/RemotePackageList.py b/plugins/Marketplace/RemotePackageList.py new file mode 100644 index 0000000000..8fa75453c1 --- /dev/null +++ b/plugins/Marketplace/RemotePackageList.py @@ -0,0 +1,137 @@ +# 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 + +catalog = i18nCatalog("cura") + + +class RemotePackageList(PackageList): + ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once. + + def __init__(self, parent: Optional["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() + self.isLoadingChanged.emit() + + 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"]: + 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 + 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/ManagePackagesButton.qml b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml new file mode 100644 index 0000000000..4a734f45ba --- /dev/null +++ b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml @@ -0,0 +1,45 @@ +// 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 + 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: backgroundColor + border.color: borderColor + border.width: UM.Theme.getSize("default_lining").width + } + + Cura.ToolTip + { + id: tooltip + + tooltipText: catalog.i18nc("@info:tooltip", "Manage packages") + 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/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..4f36de01d0 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: content.item ? content.item.pageTitle: catalog.i18nc("@title", "Loading...") } } @@ -72,10 +75,24 @@ Window Layout.preferredWidth: parent.width Layout.preferredHeight: childrenRect.height - TabBar //Page selection. + ManagePackagesButton + { + id: managePackagesButton + + anchors.right: parent.right + anchors.rightMargin: UM.Theme.getSize("default_margin").width + + 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 @@ -83,34 +100,43 @@ Window PackageTypeTab { width: implicitWidth - text: catalog.i18nc("@button", "Plug-ins") - pageTitle: catalog.i18nc("@header", "Install Plugins") + text: catalog.i18nc("@button", "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 + function onLoaded() + { + 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..c9ddf88d16 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 { @@ -23,6 +24,28 @@ ScrollView spacing: UM.Theme.getSize("default_margin").height + section.property: "package.sectionTitle" + section.delegate: Rectangle + { + width: packagesListview.width + height: sectionHeaderText.height + 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 @@ -43,10 +66,12 @@ 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 + height: model.hasFooter || packages.model.errorMessage != "" ? UM.Theme.getSize("card").height + packagesListview.spacing : 0 + visible: model.hasFooter || packages.model.errorMessage != "" Button { id: loadMoreButton @@ -55,7 +80,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 @@ + + +