diff --git a/cura/CuraPackageManager.py b/cura/CuraPackageManager.py index 26d6591099..af75aa7b66 100644 --- a/cura/CuraPackageManager.py +++ b/cura/CuraPackageManager.py @@ -1,13 +1,15 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List, Tuple, TYPE_CHECKING, Optional +from typing import Any, cast, Dict, List, Set, Tuple, TYPE_CHECKING, Optional -from cura.CuraApplication import CuraApplication #To find some resource types. +from cura.CuraApplication import CuraApplication # To find some resource types. from cura.Settings.GlobalStack import GlobalStack -from UM.PackageManager import PackageManager #The class we're extending. -from UM.Resources import Resources #To find storage paths for some resource types. +from UM.PackageManager import PackageManager # The class we're extending. +from UM.Resources import Resources # To find storage paths for some resource types. +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") if TYPE_CHECKING: from UM.Qt.QtApplication import QtApplication @@ -17,6 +19,31 @@ if TYPE_CHECKING: class CuraPackageManager(PackageManager): def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None) -> None: super().__init__(application, parent) + self._local_packages: Optional[List[Dict[str, Any]]] = None + self._local_packages_ids: Optional[Set[str]] = None + self.installedPackagesChanged.connect(self._updateLocalPackages) + + def _updateLocalPackages(self) -> None: + self._local_packages = self.getAllLocalPackages() + self._local_packages_ids = set(pkg["package_id"] for pkg in self._local_packages) + + @property + def local_packages(self) -> List[Dict[str, Any]]: + """locally installed packages, lazy execution""" + if self._local_packages is None: + self._updateLocalPackages() + # _updateLocalPackages always results in a list of packages, not None. + # It's guaranteed to be a list now. + return cast(List[Dict[str, Any]], self._local_packages) + + @property + def local_packages_ids(self) -> Set[str]: + """locally installed packages, lazy execution""" + if self._local_packages_ids is None: + self._updateLocalPackages() + # _updateLocalPackages always results in a list of packages, not None. + # It's guaranteed to be a list now. + return cast(Set[str], self._local_packages_ids) def initialize(self) -> None: self._installation_dirs_dict["materials"] = Resources.getStoragePath(CuraApplication.ResourceTypes.MaterialInstanceContainer) @@ -47,3 +74,12 @@ class CuraPackageManager(PackageManager): machine_with_qualities.append((global_stack, str(extruder_nr), container_id)) return machine_with_materials, machine_with_qualities + + def getAllLocalPackages(self) -> List[Dict[str, Any]]: + """ Returns an unordered list of all the package_info of installed, to be installed, or bundled packages""" + packages: List[Dict[str, Any]] = [] + + for packages_to_add in self.getAllInstalledPackagesInfo().values(): + packages.extend(packages_to_add) + + return packages diff --git a/plugins/Marketplace/Constants.py b/plugins/Marketplace/Constants.py new file mode 100644 index 0000000000..9f0f78b966 --- /dev/null +++ b/plugins/Marketplace/Constants.py @@ -0,0 +1,12 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from cura.UltimakerCloud import UltimakerCloudConstants +from cura.ApplicationMetadata import CuraSDKVersion + +ROOT_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/cura-packages/v{UltimakerCloudConstants.CuraCloudAPIVersion}" +ROOT_CURA_URL = f"{ROOT_URL}/cura/v{CuraSDKVersion}" # Root of all Marketplace API requests. +ROOT_USER_URL = f"{ROOT_URL}/user" +PACKAGES_URL = f"{ROOT_CURA_URL}/packages" # URL to use for requesting the list of packages. +PACKAGE_UPDATES_URL = f"{PACKAGES_URL}/package-updates" # URL to use for requesting the list of packages that can be updated. +USER_PACKAGES_URL = f"{ROOT_USER_URL}/packages" diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py index 7e9bd82cdb..8adb1e841e 100644 --- a/plugins/Marketplace/LocalPackageList.py +++ b/plugins/Marketplace/LocalPackageList.py @@ -1,40 +1,66 @@ -# Copyright (c) 2021 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from typing import Any, Dict, List, Optional, TYPE_CHECKING +from operator import attrgetter -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 PyQt5.QtNetwork import QNetworkReply from UM.i18n import i18nCatalog - -from cura.CuraApplication import CuraApplication +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from UM.Logger import Logger from .PackageList import PackageList -from .PackageModel import PackageModel # The contents of this list. +from .PackageModel import PackageModel +from .Constants import PACKAGE_UPDATES_URL catalog = i18nCatalog("cura") class LocalPackageList(PackageList): - PACKAGE_SECTION_HEADER = { + PACKAGE_CATEGORIES = { "installed": { - "plugin": catalog.i18nc("@label:property", "Installed Plugins"), - "material": catalog.i18nc("@label:property", "Installed Materials") + "plugin": catalog.i18nc("@label", "Installed Plugins"), + "material": catalog.i18nc("@label", "Installed Materials") }, "bundled": { - "plugin": catalog.i18nc("@label:property", "Bundled Plugins"), - "material": catalog.i18nc("@label:property", "Bundled Materials") + "plugin": catalog.i18nc("@label", "Bundled Plugins"), + "material": catalog.i18nc("@label", "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 + self._ongoing_requests["check_updates"] = None + self._package_manager.packagesWithUpdateChanged.connect(self._sortSectionsOnUpdate) + self._package_manager.packageUninstalled.connect(self._removePackageModel) + + def _sortSectionsOnUpdate(self) -> None: + section_order = dict(zip([i for k, v in self.PACKAGE_CATEGORIES.items() for i in self.PACKAGE_CATEGORIES[k].values()], ["a", "b", "c", "d"])) + self.sort(lambda model: (section_order[model.sectionTitle], model.canUpdate, model.displayName.lower()), key = "package") + + def _removePackageModel(self, package_id: str) -> None: + """ + Cleanup function to remove the package model from the list. Note that this is only done if the package can't + be updated, it is in the to remove list and isn't in the to be installed list + """ + package = self.getPackageModel(package_id) + if not package.canUpdate and \ + package_id in self._package_manager.getToRemovePackageIDs() and \ + package_id not in self._package_manager.getPackagesToInstall(): + index = self.find("package", package_id) + if index < 0: + Logger.error(f"Could not find card in Listview corresponding with {package_id}") + self.updatePackages() + return + self.removeItem(index) @pyqtSlot() def updatePackages(self) -> None: @@ -44,50 +70,52 @@ class LocalPackageList(PackageList): """ self.setErrorMessage("") # Clear any previous errors. self.setIsLoading(True) - self._getLocalPackages() + + # Obtain and sort the local packages + self.setItems([{"package": p} for p in [self._makePackageModel(p) for p in self._package_manager.local_packages]]) + self._sortSectionsOnUpdate() + self.checkForUpdates(self._package_manager.local_packages) + 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_id = package_info["package_id"] + bundled_or_installed = "bundled" if self._package_manager.isBundledPackage(package_id) else "installed" package_type = package_info["package_type"] - section_title = self.PACKAGE_SECTION_HEADER[bundled_or_installed][package_type] - return PackageModel(package_info, installation_status = bundled_or_installed, section_title = section_title, parent = self) + section_title = self.PACKAGE_CATEGORIES[bundled_or_installed][package_type] + package = PackageModel(package_info, section_title = section_title, parent = self) + self._connectManageButtonSignals(package) + return package + + def checkForUpdates(self, packages: List[Dict[str, Any]]) -> None: + installed_packages = "&".join([f"installed_packages={package['package_id']}:{package['package_version']}" for package in packages]) + request_url = f"{PACKAGE_UPDATES_URL}?installed_packages={installed_packages[:-1]}" + + self._ongoing_requests["check_updates"] = HttpRequestManager.getInstance().get( + request_url, + scope = self._scope, + callback = self._parseResponse + ) + + def _parseResponse(self, reply: "QNetworkReply") -> None: + """ + Parse the response from the package list API request which can update. + + :param reply: A reply containing information about a number of packages. + """ + response_data = HttpRequestManager.readJSON(reply) + if "data" not in response_data: + Logger.error( + f"Could not interpret the server's response. Missing 'data' from response data. Keys in response: {response_data.keys()}") + return + if len(response_data["data"]) == 0: + return + + packages = response_data["data"] + + self._package_manager.setPackagesWithUpdate({p['package_id'] for p in packages}) + + self._ongoing_requests["check_updates"] = None diff --git a/plugins/Marketplace/Marketplace.py b/plugins/Marketplace/Marketplace.py index 18d80d6e68..143469d82e 100644 --- a/plugins/Marketplace/Marketplace.py +++ b/plugins/Marketplace/Marketplace.py @@ -6,22 +6,18 @@ from PyQt5.QtCore import pyqtSlot from PyQt5.QtQml import qmlRegisterType 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.PluginRegistry import PluginRegistry # To find out where we are stored (the proper way). from .RemotePackageList import RemotePackageList # To register this type with QML. from .LocalPackageList import LocalPackageList # To register this type with QML. +from .RestartManager import RestartManager # To register this type with QML. if TYPE_CHECKING: from PyQt5.QtCore import QObject -ROOT_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/cura-packages/v{UltimakerCloudConstants.CuraCloudAPIVersion}/cura/v{CuraSDKVersion}" # Root of all Marketplace API requests. -PACKAGES_URL = f"{ROOT_URL}/packages" # URL to use for requesting the list of packages. - class Marketplace(Extension): """ @@ -31,9 +27,11 @@ class Marketplace(Extension): def __init__(self) -> None: super().__init__() self._window: Optional["QObject"] = None # If the window has been loaded yet, it'll be cached in here. + self._plugin_registry: Optional[PluginRegistry] = None qmlRegisterType(RemotePackageList, "Marketplace", 1, 0, "RemotePackageList") qmlRegisterType(LocalPackageList, "Marketplace", 1, 0, "LocalPackageList") + qmlRegisterType(RestartManager, "Marketplace", 1, 0, "RestartManager") @pyqtSlot() def show(self) -> None: @@ -43,6 +41,7 @@ class Marketplace(Extension): If the window hadn't been loaded yet into Qt, it will be created lazily. """ if self._window is None: + self._plugin_registry = PluginRegistry.getInstance() plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) if plugin_path is None: plugin_path = os.path.dirname(__file__) diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py index 8171d168f2..1a76d65141 100644 --- a/plugins/Marketplace/PackageList.py +++ b/plugins/Marketplace/PackageList.py @@ -1,14 +1,29 @@ -# Copyright (c) 2021 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import tempfile +import json +import os.path from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt -from typing import Optional, TYPE_CHECKING +from typing import cast, Dict, Optional, Set, TYPE_CHECKING from UM.i18n import i18nCatalog from UM.Qt.ListModel import ListModel +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope +from UM.TaskManagement.HttpRequestManager import HttpRequestData, HttpRequestManager +from UM.Logger import Logger +from UM import PluginRegistry + +from cura.CuraApplication import CuraApplication +from cura.CuraPackageManager import CuraPackageManager +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization. + +from .PackageModel import PackageModel +from .Constants import USER_PACKAGES_URL, PACKAGES_URL if TYPE_CHECKING: from PyQt5.QtCore import QObject + from PyQt5.QtNetwork import QNetworkReply catalog = i18nCatalog("cura") @@ -18,26 +33,51 @@ class PackageList(ListModel): such as Packages obtained from Remote or Local source """ PackageRole = Qt.UserRole + 1 + DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB def __init__(self, parent: Optional["QObject"] = None) -> None: super().__init__(parent) + self._package_manager: CuraPackageManager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager()) + self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry() + self._account = CuraApplication.getInstance().getCuraAPI().account self._error_message = "" self.addRoleName(self.PackageRole, "package") self._is_loading = False self._has_more = False self._has_footer = True + self._to_install: Dict[str, str] = {} + + self._ongoing_requests: Dict[str, Optional[HttpRequestData]] = {"download_package": None} + self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance())) + self._license_dialogs: Dict[str, QObject] = {} + + def __del__(self) -> None: + """ When this object is deleted it will loop through all registered API requests and aborts them """ + + try: + self.isLoadingChanged.disconnect() + self.hasMoreChanged.disconnect() + except RuntimeError: + pass + + self.cleanUpAPIRequest() + + def abortRequest(self, request_id: str) -> None: + """Aborts a single request""" + if request_id in self._ongoing_requests and self._ongoing_requests[request_id]: + HttpRequestManager.getInstance().abortRequest(self._ongoing_requests[request_id]) + self._ongoing_requests[request_id] = None + + @pyqtSlot() + def cleanUpAPIRequest(self) -> None: + for request_id in self._ongoing_requests: + self.abortRequest(request_id) @pyqtSlot() 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 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() @@ -91,3 +131,170 @@ class PackageList(ListModel): """ 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 + + def getPackageModel(self, package_id: str) -> PackageModel: + index = self.find("package", package_id) + return self.getItem(index)["package"] + + def _openLicenseDialog(self, package_id: str, license_content: str) -> None: + plugin_path = self._plugin_registry.getPluginPath("Marketplace") + if plugin_path is None: + plugin_path = os.path.dirname(__file__) + + # create a QML component for the license dialog + license_dialog_component_path = os.path.join(plugin_path, "resources", "qml", "LicenseDialog.qml") + dialog = CuraApplication.getInstance().createQmlComponent(license_dialog_component_path, { + "licenseContent": license_content, + "packageId": package_id, + "handler": self + }) + dialog.show() + # place dialog in class such that it does not get remove by garbage collector + self._license_dialogs[package_id] = dialog + + @pyqtSlot(str) + def onLicenseAccepted(self, package_id: str) -> None: + # close dialog + dialog = self._license_dialogs.pop(package_id) + if dialog is not None: + dialog.deleteLater() + # install relevant package + self._install(package_id) + + @pyqtSlot(str) + def onLicenseDeclined(self, package_id: str) -> None: + # close dialog + dialog = self._license_dialogs.pop(package_id) + if dialog is not None: + dialog.deleteLater() + # reset package card + self._package_manager.packageInstallingFailed.emit(package_id) + + def _requestInstall(self, package_id: str, update: bool = False) -> None: + package_path = self._to_install[package_id] + license_content = self._package_manager.getPackageLicense(package_path) + + if not update and license_content is not None and license_content != "": + # If installation is not and update, and the packages contains a license then + # open dialog, prompting the using to accept the plugin license + self._openLicenseDialog(package_id, license_content) + else: + # Otherwise continue the installation + self._install(package_id, update) + + def _install(self, package_id: str, update: bool = False) -> None: + package_path = self._to_install.pop(package_id) + to_be_installed = self._package_manager.installPackage(package_path) is not None + if not to_be_installed: + Logger.warning(f"Could not install {package_id}") + return + package = self.getPackageModel(package_id) + self.subscribeUserToPackage(package_id, str(package.sdk_version)) + + def download(self, package_id: str, url: str, update: bool = False) -> None: + """Initiate the download request + + :param package_id: the package identification string + :param url: the URL from which the package needs to be obtained + :param update: A flag if this is download request is an update process + """ + + if url == "": + url = f"{PACKAGES_URL}/{package_id}/download" + + def downloadFinished(reply: "QNetworkReply") -> None: + self._downloadFinished(package_id, reply, update) + + def downloadError(reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None: + self._downloadError(package_id, update, reply, error) + + self._ongoing_requests["download_package"] = HttpRequestManager.getInstance().get( + url, + scope = self._scope, + callback = downloadFinished, + error_callback = downloadError + ) + + def _downloadFinished(self, package_id: str, reply: "QNetworkReply", update: bool = False) -> None: + with tempfile.NamedTemporaryFile(mode = "wb+", suffix = ".curapackage", delete = False) as temp_file: + try: + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) + while bytes_read: + temp_file.write(bytes_read) + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) + except IOError as e: + Logger.error(f"Failed to write downloaded package to temp file {e}") + temp_file.close() + self._downloadError(package_id, update) + 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 Remote or Local PackageLists. This try-except is here to prevent a hard crash when the wrapped C++ object + # was deleted when it was still parsing the response + temp_file.close() + return + temp_file.close() + self._to_install[package_id] = temp_file.name + self._ongoing_requests["download_package"] = None + self._requestInstall(package_id, update) + + + def _downloadError(self, package_id: str, update: bool = False, reply: Optional["QNetworkReply"] = None, error: Optional["QNetworkReply.NetworkError"] = None) -> None: + if reply: + reply_string = bytes(reply.readAll()).decode() + Logger.error(f"Failed to download package: {package_id} due to {reply_string}") + self._package_manager.packageInstallingFailed.emit(package_id) + + def subscribeUserToPackage(self, package_id: str, sdk_version: str) -> None: + """Subscribe the user (if logged in) to the package for a given SDK + + :param package_id: the package identification string + :param sdk_version: the SDK version + """ + if self._account.isLoggedIn: + HttpRequestManager.getInstance().put( + url = USER_PACKAGES_URL, + data = json.dumps({"data": {"package_id": package_id, "sdk_version": sdk_version}}).encode(), + scope = self._scope + ) + + def unsunscribeUserFromPackage(self, package_id: str) -> None: + """Unsubscribe the user (if logged in) from the package + + :param package_id: the package identification string + """ + if self._account.isLoggedIn: + HttpRequestManager.getInstance().delete(url = f"{USER_PACKAGES_URL}/{package_id}", scope = self._scope) + + # --- Handle the manage package buttons --- + + def _connectManageButtonSignals(self, package: PackageModel) -> None: + package.installPackageTriggered.connect(self.installPackage) + package.uninstallPackageTriggered.connect(self.uninstallPackage) + package.updatePackageTriggered.connect(self.updatePackage) + + def installPackage(self, package_id: str, url: str) -> None: + """Install a package from the Marketplace + + :param package_id: the package identification string + """ + if not self._package_manager.reinstallPackage(package_id): + self.download(package_id, url, False) + else: + package = self.getPackageModel(package_id) + self.subscribeUserToPackage(package_id, str(package.sdk_version)) + + def uninstallPackage(self, package_id: str) -> None: + """Uninstall a package from the Marketplace + + :param package_id: the package identification string + """ + self._package_manager.removePackage(package_id) + self.unsunscribeUserFromPackage(package_id) + + def updatePackage(self, package_id: str, url: str) -> None: + """Update a package from the Marketplace + + :param package_id: the package identification string + """ + self._package_manager.removePackage(package_id, force_add = not self._package_manager.isBundledPackage(package_id)) + self.download(package_id, url, True) diff --git a/plugins/Marketplace/PackageModel.py b/plugins/Marketplace/PackageModel.py index 9b8c873827..307cdce986 100644 --- a/plugins/Marketplace/PackageModel.py +++ b/plugins/Marketplace/PackageModel.py @@ -1,12 +1,19 @@ -# Copyright (c) 2021 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import pyqtProperty, QObject import re -from typing import Any, Dict, List, Optional +from enum import Enum +from typing import Any, cast, Dict, List, Optional +from PyQt5.QtCore import pyqtProperty, QObject, pyqtSignal, pyqtSlot +from PyQt5.QtQml import QQmlEngine + +from cura.CuraApplication import CuraApplication +from cura.CuraPackageManager import CuraPackageManager from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To get names of materials we're compatible with. from UM.i18n import i18nCatalog # To translate placeholder names if data is not present. +from UM.Logger import Logger +from UM.PluginRegistry import PluginRegistry catalog = i18nCatalog("cura") @@ -14,26 +21,28 @@ catalog = i18nCatalog("cura") class PackageModel(QObject): """ Represents a package, containing all the relevant information to be displayed about a package. - - Effectively this behaves like a glorified named tuple, but as a QObject so that its properties can be obtained from - QML. The model can also be constructed directly from a response received by the API. """ - def __init__(self, package_data: Dict[str, Any], installation_status: str, section_title: Optional[str] = None, parent: Optional[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 installation_status: Whether the package is `not_installed`, `installed` or `bundled`. :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) + QQmlEngine.setObjectOwnership(self, QQmlEngine.CppOwnership) + self._package_manager: CuraPackageManager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager()) + self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry() + self._package_id = package_data.get("package_id", "UnknownPackageId") self._package_type = package_data.get("package_type", "") + self._is_bundled = package_data.get("is_bundled", False) self._icon_url = package_data.get("icon_url", "") self._display_name = package_data.get("display_name", catalog.i18nc("@label:property", "Unknown Package")) tags = package_data.get("tags", []) - self._is_checked_by_ultimaker = (self._package_type == "plugin" and "verified" in tags) or (self._package_type == "material" and "certified" in tags) + self._is_checked_by_ultimaker = (self._package_type == "plugin" and "verified" in tags) or ( + self._package_type == "material" and "certified" in tags) self._package_version = package_data.get("package_version", "") # Display purpose, no need for 'UM.Version'. self._package_info_url = package_data.get("website", "") # Not to be confused with 'download_url'. self._download_count = package_data.get("download_count", 0) @@ -58,10 +67,40 @@ class PackageModel(QObject): if not self._icon_url or self._icon_url == "": self._icon_url = author_data.get("icon_url", "") - self._installation_status = installation_status + self._can_update = False self._section_title = section_title + self.sdk_version = package_data.get("sdk_version_semver", "") # Note that there's a lot more info in the package_data than just these specified here. + self.enablePackageTriggered.connect(self._plugin_registry.enablePlugin) + self.disablePackageTriggered.connect(self._plugin_registry.disablePlugin) + + self._plugin_registry.pluginsEnabledOrDisabledChanged.connect(self.stateManageButtonChanged) + self._package_manager.packageInstalled.connect(lambda pkg_id: self._packageInstalled(pkg_id)) + self._package_manager.packageUninstalled.connect(lambda pkg_id: self._packageInstalled(pkg_id)) + self._package_manager.packageInstallingFailed.connect(lambda pkg_id: self._packageInstalled(pkg_id)) + self._package_manager.packagesWithUpdateChanged.connect(self._processUpdatedPackages) + + self._is_busy = False + + @pyqtSlot() + def _processUpdatedPackages(self): + self.setCanUpdate(self._package_manager.checkIfPackageCanUpdate(self._package_id)) + + def __del__(self): + self._package_manager.packagesWithUpdateChanged.disconnect(self._processUpdatedPackages) + + def __eq__(self, other: object) -> bool: + if isinstance(other, PackageModel): + return other == self + elif isinstance(other, str): + return other == self._package_id + else: + return False + + def __repr__(self) -> str: + return f"<{self._package_id} : {self._package_version} : {self._section_title}>" + def _findLink(self, subdata: Dict[str, Any], link_type: str) -> str: """ Searches the package data for a link of a certain type. @@ -175,8 +214,8 @@ class PackageModel(QObject): def packageType(self) -> str: return self._package_type - @pyqtProperty(str, constant=True) - def iconUrl(self): + @pyqtProperty(str, constant = True) + def iconUrl(self) -> str: return self._icon_url @pyqtProperty(str, constant = True) @@ -187,37 +226,33 @@ class PackageModel(QObject): def isCheckedByUltimaker(self): return self._is_checked_by_ultimaker - @pyqtProperty(str, constant=True) - def packageVersion(self): + @pyqtProperty(str, constant = True) + def packageVersion(self) -> str: return self._package_version - @pyqtProperty(str, constant=True) - def packageInfoUrl(self): + @pyqtProperty(str, constant = True) + def packageInfoUrl(self) -> str: return self._package_info_url - @pyqtProperty(int, constant=True) - def downloadCount(self): + @pyqtProperty(int, constant = True) + def downloadCount(self) -> str: return self._download_count - @pyqtProperty(str, constant=True) - def description(self): + @pyqtProperty(str, constant = True) + def description(self) -> str: return self._description @pyqtProperty(str, constant = True) def formattedDescription(self) -> str: return self._formatted_description - @pyqtProperty(str, constant=True) - def authorName(self): + @pyqtProperty(str, constant = True) + def authorName(self) -> str: return self._author_name - @pyqtProperty(str, constant=True) - def authorInfoUrl(self): - return self._author_info_url - @pyqtProperty(str, constant = True) - def installationStatus(self) -> str: - return self._installation_status + def authorInfoUrl(self) -> str: + return self._author_info_url @pyqtProperty(str, constant = True) def sectionTitle(self) -> Optional[str]: @@ -250,3 +285,99 @@ class PackageModel(QObject): @pyqtProperty(bool, constant = True) def isCompatibleAirManager(self) -> bool: return self._is_compatible_air_manager + + @pyqtProperty(bool, constant = True) + def isBundled(self) -> bool: + return self._is_bundled + + @pyqtProperty(str, constant = True) + def downloadURL(self) -> str: + return self._download_url + + # --- manage buttons signals --- + + stateManageButtonChanged = pyqtSignal() + + installPackageTriggered = pyqtSignal(str, str) + + uninstallPackageTriggered = pyqtSignal(str) + + updatePackageTriggered = pyqtSignal(str, str) + + enablePackageTriggered = pyqtSignal(str) + + disablePackageTriggered = pyqtSignal(str) + + busyChanged = pyqtSignal() + + @pyqtSlot() + def install(self): + self.setBusy(True) + self.installPackageTriggered.emit(self.packageId, self.downloadURL) + + @pyqtSlot() + def update(self): + self.setBusy(True) + self.updatePackageTriggered.emit(self.packageId, self.downloadURL) + + @pyqtSlot() + def uninstall(self): + self.uninstallPackageTriggered.emit(self.packageId) + + @pyqtProperty(bool, notify= busyChanged) + def busy(self): + """ + Property indicating that some kind of upgrade is active. + """ + return self._is_busy + + @pyqtSlot() + def enable(self): + self.enablePackageTriggered.emit(self.packageId) + + @pyqtSlot() + def disable(self): + self.disablePackageTriggered.emit(self.packageId) + + def setBusy(self, value: bool): + if self._is_busy != value: + self._is_busy = value + try: + self.busyChanged.emit() + except RuntimeError: + pass + + def _packageInstalled(self, package_id: str) -> None: + if self._package_id != package_id: + return + self.setBusy(False) + try: + self.stateManageButtonChanged.emit() + except RuntimeError: + pass + + @pyqtProperty(bool, notify = stateManageButtonChanged) + def isInstalled(self) -> bool: + return self._package_id in self._package_manager.getAllInstalledPackageIDs() + + @pyqtProperty(bool, notify = stateManageButtonChanged) + def isToBeInstalled(self) -> bool: + return self._package_id in self._package_manager.getPackagesToInstall() + + @pyqtProperty(bool, notify = stateManageButtonChanged) + def isActive(self) -> bool: + return not self._package_id in self._plugin_registry.getDisabledPlugins() + + @pyqtProperty(bool, notify = stateManageButtonChanged) + def canDowngrade(self) -> bool: + """Flag if the installed package can be downgraded to a bundled version""" + return self._package_manager.canDowngrade(self._package_id) + + def setCanUpdate(self, value: bool) -> None: + self._can_update = value + self.stateManageButtonChanged.emit() + + @pyqtProperty(bool, fset = setCanUpdate, notify = stateManageButtonChanged) + def canUpdate(self) -> bool: + """Flag indicating if the package can be updated""" + return self._can_update diff --git a/plugins/Marketplace/RemotePackageList.py b/plugins/Marketplace/RemotePackageList.py index 6241ce0d2c..16b0e721ad 100644 --- a/plugins/Marketplace/RemotePackageList.py +++ b/plugins/Marketplace/RemotePackageList.py @@ -1,18 +1,15 @@ -# Copyright (c) 2021 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. +# 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 UM.TaskManagement.HttpRequestManager import HttpRequestManager # To request the package list from the API. -from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports. +from .Constants import PACKAGES_URL # 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. @@ -28,23 +25,14 @@ class RemotePackageList(PackageList): 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._requested_search_string = "" self._current_search_string = "" self._request_url = self._initialRequestUrl() + self._ongoing_requests["get_packages"] = None self.isLoadingChanged.connect(self._onLoadingChanged) 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: """ @@ -55,18 +43,13 @@ class RemotePackageList(PackageList): self.setErrorMessage("") # Clear any previous errors. self.setIsLoading(True) - self._ongoing_request = HttpRequestManager.getInstance().get( + self._ongoing_requests["get_packages"] = 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() @@ -113,7 +96,7 @@ class RemotePackageList(PackageList): Get the URL to request the first paginated page with. :return: A URL to request. """ - request_url = f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}" + request_url = f"{PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}" if self._package_type_filter != "": request_url += f"&package_type={self._package_type_filter}" if self._current_search_string != "": @@ -134,18 +117,21 @@ class RemotePackageList(PackageList): return for package_data in response_data["data"]: - installation_status = "installed" if CuraApplication.getInstance().getPackageManager().isUserInstalledPackage(package_data["package_id"]) else "not_installed" + package_id = package_data["package_id"] + if package_id in self._package_manager.local_packages_ids: + continue # We should only show packages which are not already installed try: - package = PackageModel(package_data, installation_status, parent = self) + package = PackageModel(package_data, parent = self) + self._connectManageButtonSignals(package) 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 + continue self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page. - self._ongoing_request = None + self._ongoing_requests["get_packages"] = None self.setIsLoading(False) self.setHasMore(self._request_url != "") @@ -157,9 +143,9 @@ class RemotePackageList(PackageList): """ if error == QNetworkReply.NetworkError.OperationCanceledError: Logger.debug("Cancelled request for packages.") - self._ongoing_request = None + self._ongoing_requests["get_packages"] = 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._ongoing_requests["get_packages"] = None self.setIsLoading(False) diff --git a/plugins/Marketplace/RestartManager.py b/plugins/Marketplace/RestartManager.py new file mode 100644 index 0000000000..9fe52b4116 --- /dev/null +++ b/plugins/Marketplace/RestartManager.py @@ -0,0 +1,36 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional, TYPE_CHECKING + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject + +from cura.CuraApplication import CuraApplication + +if TYPE_CHECKING: + from UM.PluginRegistry import PluginRegistry + from cura.CuraPackageManager import CuraPackageManager + + +class RestartManager(QObject): + def __init__(self, parent: Optional[QObject] = None) -> None: + super().__init__(parent = parent) + self._manager: "CuraPackageManager" = CuraApplication.getInstance().getPackageManager() + self._plugin_registry: "PluginRegistry" = CuraApplication.getInstance().getPluginRegistry() + + self._manager.installedPackagesChanged.connect(self.checkIfRestartNeeded) + self._plugin_registry.pluginsEnabledOrDisabledChanged.connect(self.checkIfRestartNeeded) + + self._restart_needed = False + + def checkIfRestartNeeded(self) -> None: + if self._manager.hasPackagesToRemoveOrInstall or len(self._plugin_registry.getCurrentSessionActivationChangedPlugins()) > 0: + self._restart_needed = True + else: + self._restart_needed = False + self.showRestartNotificationChanged.emit() + + showRestartNotificationChanged = pyqtSignal() + + @pyqtProperty(bool, notify = showRestartNotificationChanged) + def showRestartNotification(self) -> bool: + return self._restart_needed diff --git a/plugins/Marketplace/resources/qml/LicenseDialog.qml b/plugins/Marketplace/resources/qml/LicenseDialog.qml new file mode 100644 index 0000000000..1c99569793 --- /dev/null +++ b/plugins/Marketplace/resources/qml/LicenseDialog.qml @@ -0,0 +1,91 @@ +//Copyright (c) 2021 Ultimaker B.V. +//Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.10 +import QtQuick.Dialogs 1.1 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 + +import UM 1.6 as UM +import Cura 1.6 as Cura + +UM.Dialog +{ + id: licenseDialog + title: catalog.i18nc("@button", "Plugin license agreement") + minimumWidth: UM.Theme.getSize("license_window_minimum").width + minimumHeight: UM.Theme.getSize("license_window_minimum").height + width: minimumWidth + height: minimumHeight + backgroundColor: UM.Theme.getColor("main_background") + + property variant catalog: UM.I18nCatalog { name: "cura" } + + ColumnLayout + { + anchors.fill: parent + spacing: UM.Theme.getSize("thick_margin").height + + Row + { + Layout.fillWidth: true + height: childrenRect.height + spacing: UM.Theme.getSize("default_margin").width + leftPadding: UM.Theme.getSize("narrow_margin").width + + UM.RecolorImage + { + id: icon + width: UM.Theme.getSize("marketplace_large_icon").width + height: UM.Theme.getSize("marketplace_large_icon").height + color: UM.Theme.getColor("text") + source: UM.Theme.getIcon("Certificate", "high") + } + + Label + { + text: catalog.i18nc("@text", "Please read and agree with the plugin licence.") + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("large") + anchors.verticalCenter: icon.verticalCenter + height: UM.Theme.getSize("marketplace_large_icon").height + verticalAlignment: Qt.AlignVCenter + wrapMode: Text.Wrap + renderType: Text.NativeRendering + } + } + + Cura.ScrollableTextArea + { + Layout.fillWidth: true + Layout.fillHeight: true + anchors.topMargin: UM.Theme.getSize("default_margin").height + + textArea.text: licenseContent + textArea.readOnly: true + } + + } + rightButtons: + [ + Cura.PrimaryButton + { + text: catalog.i18nc("@button", "Accept") + onClicked: handler.onLicenseAccepted(packageId) + } + ] + + leftButtons: + [ + Cura.SecondaryButton + { + text: catalog.i18nc("@button", "Decline") + onClicked: handler.onLicenseDeclined(packageId) + } + ] + + onAccepted: handler.onLicenseAccepted(packageId) + onRejected: handler.onLicenseDeclined(packageId) + onClosing: handler.onLicenseDeclined(packageId) +} diff --git a/plugins/Marketplace/resources/qml/ManageButton.qml b/plugins/Marketplace/resources/qml/ManageButton.qml new file mode 100644 index 0000000000..36022ffd54 --- /dev/null +++ b/plugins/Marketplace/resources/qml/ManageButton.qml @@ -0,0 +1,114 @@ +// 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.1 + +import UM 1.6 as UM +import Cura 1.6 as Cura + +Item +{ + id: manageButton + property bool button_style: true + property string text + property bool busy: false + property bool confirmed: false + + implicitWidth: childrenRect.width + implicitHeight: childrenRect.height + + signal clicked() + + property Component primaryButton: Component + { + Cura.PrimaryButton + { + text: manageButton.text + onClicked: manageButton.clicked() + } + } + + property Component secondaryButton: Component + { + Cura.SecondaryButton + { + text: manageButton.text + onClicked: manageButton.clicked() + } + } + + property Component busyButton: Component + { + Item + { + height: UM.Theme.getSize("action_button").height + width: childrenRect.width + + UM.RecolorImage + { + id: busyIndicator + visible: parent.visible + height: UM.Theme.getSize("action_button").height - 2 * UM.Theme.getSize("narrow_margin").height + width: height + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + source: UM.Theme.getIcon("Spinner") + color: UM.Theme.getColor("primary") + + RotationAnimator + { + target: busyIndicator + running: parent.visible + from: 0 + to: 360 + loops: Animation.Infinite + duration: 2500 + } + } + Label + { + visible: parent.visible + anchors.left: busyIndicator.right + anchors.leftMargin: UM.Theme.getSize("narrow_margin").width + anchors.verticalCenter: parent.verticalCenter + text: manageButton.text + + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("primary") + } + } + } + + property Component confirmButton: Component + { + Item + { + height: UM.Theme.getSize("action_button").height + width: childrenRect.width + + Label + { + anchors.verticalCenter: parent.verticalCenter + text: manageButton.text + + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("primary") + } + } + } + + Loader + { + + sourceComponent: + { + if (busy) { return manageButton.busyButton; } + else if (confirmed) { return manageButton.confirmButton; } + else if (manageButton.button_style) { return manageButton.primaryButton; } + else { return manageButton.secondaryButton; } + } + } +} diff --git a/plugins/Marketplace/resources/qml/ManagedPackages.qml b/plugins/Marketplace/resources/qml/ManagedPackages.qml index f44fbd0a9b..dbdc04bf52 100644 --- a/plugins/Marketplace/resources/qml/ManagedPackages.qml +++ b/plugins/Marketplace/resources/qml/ManagedPackages.qml @@ -20,6 +20,7 @@ Packages bannerVisible = false; } searchInBrowserUrl: "https://marketplace.ultimaker.com/app/cura/plugins?utm_source=cura&utm_medium=software&utm_campaign=marketplace-search-plugins-browser" + packagesManageableInListView: true model: Marketplace.LocalPackageList { diff --git a/plugins/Marketplace/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml index 44f7777b35..017a9e3dde 100644 --- a/plugins/Marketplace/resources/qml/Marketplace.qml +++ b/plugins/Marketplace/resources/qml/Marketplace.qml @@ -8,11 +8,13 @@ import QtQuick.Window 2.2 import UM 1.2 as UM import Cura 1.6 as Cura +import Marketplace 1.0 as Marketplace Window { id: marketplaceDialog property variant catalog: UM.I18nCatalog { name: "cura" } + property variant restartManager: Marketplace.RestartManager { } signal searchStringChanged(string new_search) @@ -106,9 +108,8 @@ Window height: UM.Theme.getSize("button_icon").height + UM.Theme.getSize("default_margin").height spacing: UM.Theme.getSize("thin_margin").width - Rectangle + Item { - color: "transparent" Layout.preferredHeight: parent.height Layout.preferredWidth: searchBar.visible ? UM.Theme.getSize("thin_margin").width : 0 Layout.fillWidth: ! searchBar.visible @@ -228,4 +229,56 @@ Window } } } + + Rectangle + { + height: quitButton.height + 2 * UM.Theme.getSize("default_margin").width + color: UM.Theme.getColor("primary") + visible: restartManager.showRestartNotification + anchors + { + left: parent.left + right: parent.right + bottom: parent.bottom + } + + RowLayout + { + anchors + { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + margins: UM.Theme.getSize("default_margin").width + } + spacing: UM.Theme.getSize("default_margin").width + UM.RecolorImage + { + id: bannerIcon + source: UM.Theme.getIcon("Plugin") + + color: UM.Theme.getColor("primary_button_text") + implicitWidth: UM.Theme.getSize("banner_icon_size").width + implicitHeight: UM.Theme.getSize("banner_icon_size").height + } + Text + { + color: UM.Theme.getColor("primary_button_text") + text: catalog.i18nc("@button", "In order to use the package you will need to restart Cura") + font: UM.Theme.getFont("default") + renderType: Text.NativeRendering + Layout.fillWidth: true + } + Cura.SecondaryButton + { + id: quitButton + text: catalog.i18nc("@info:button, %1 is the application name", "Quit %1").arg(CuraApplication.applicationDisplayName) + onClicked: + { + marketplaceDialog.hide(); + CuraApplication.closeApplication(); + } + } + } + } } diff --git a/plugins/Marketplace/resources/qml/Materials.qml b/plugins/Marketplace/resources/qml/Materials.qml index 39d283b0a5..d19f3a4b04 100644 --- a/plugins/Marketplace/resources/qml/Materials.qml +++ b/plugins/Marketplace/resources/qml/Materials.qml @@ -17,6 +17,7 @@ Packages bannerVisible = false; } searchInBrowserUrl: "https://marketplace.ultimaker.com/app/cura/materials?utm_source=cura&utm_medium=software&utm_campaign=marketplace-search-materials-browser" + packagesManageableInListView: false model: Marketplace.RemotePackageList { diff --git a/plugins/Marketplace/resources/qml/PackageCard.qml b/plugins/Marketplace/resources/qml/PackageCard.qml index b8f815bedf..633d2b25b9 100644 --- a/plugins/Marketplace/resources/qml/PackageCard.qml +++ b/plugins/Marketplace/resources/qml/PackageCard.qml @@ -10,597 +10,85 @@ import Cura 1.6 as Cura Rectangle { - property var packageData - property bool expanded: false + property alias packageData: packageCardHeader.packageData + property alias manageableInListView: packageCardHeader.showManageButtons height: childrenRect.height color: UM.Theme.getColor("main_background") radius: UM.Theme.getSize("default_radius").width - states: - [ - State - { - name: "Folded" - when: !expanded - PropertyChanges - { - target: shortDescription - visible: true - } - PropertyChanges - { - target: downloadCount - visible: false - } - PropertyChanges - { - target: extendedDescription - visible: false - } - }, - State - { - name: "Expanded" - when: expanded - PropertyChanges - { - target: shortDescription - visible: false - } - PropertyChanges - { - target: downloadCount - visible: true - } - PropertyChanges - { - target: extendedDescription - visible: true - } - } - ] - - Column + PackageCardHeader { - width: parent.width - - spacing: 0 + id: packageCardHeader Item { - width: parent.width - height: UM.Theme.getSize("card").height + id: shortDescription - Image - { - id: packageItem - anchors - { - top: parent.top - left: parent.left - margins: UM.Theme.getSize("default_margin").width - } - width: UM.Theme.getSize("card_icon").width - height: width - - source: packageData.iconUrl != "" ? packageData.iconUrl : "../images/placeholder.svg" - } - - ColumnLayout - { - anchors - { - left: packageItem.right - leftMargin: UM.Theme.getSize("default_margin").width - right: parent.right - rightMargin: UM.Theme.getSize("thick_margin").width - top: parent.top - topMargin: UM.Theme.getSize("narrow_margin").height - } - height: packageItem.height + packageItem.anchors.margins * 2 - - // Title row. - RowLayout - { - id: titleBar - Layout.preferredWidth: parent.width - Layout.preferredHeight: childrenRect.height - - Label - { - text: packageData.displayName - font: UM.Theme.getFont("medium_bold") - color: UM.Theme.getColor("text") - verticalAlignment: Text.AlignTop - } - - Control - { - Layout.preferredWidth: UM.Theme.getSize("card_tiny_icon").width - Layout.preferredHeight: UM.Theme.getSize("card_tiny_icon").height - - enabled: packageData.isCheckedByUltimaker - visible: packageData.isCheckedByUltimaker - - Cura.ToolTip - { - tooltipText: - { - switch(packageData.packageType) - { - case "plugin": return catalog.i18nc("@info", "Ultimaker Verified Plug-in"); - case "material": return catalog.i18nc("@info", "Ultimaker Certified Material"); - default: return catalog.i18nc("@info", "Ultimaker Verified Package"); - } - } - visible: parent.hovered - targetPoint: Qt.point(0, Math.round(parent.y + parent.height / 4)) - } - - Rectangle - { - anchors.fill: parent - color: UM.Theme.getColor("action_button_hovered") - radius: width - UM.RecolorImage - { - anchors.fill: parent - color: UM.Theme.getColor("primary") - source: packageData.packageType == "plugin" ? UM.Theme.getIcon("CheckCircle") : UM.Theme.getIcon("Certified") - } - } - - //NOTE: Can we link to something here? (Probably a static link explaining what verified is): - // onClicked: Qt.openUrlExternally( XXXXXX ) - } - - Control - { - Layout.preferredWidth: UM.Theme.getSize("card_tiny_icon").width - Layout.preferredHeight: UM.Theme.getSize("card_tiny_icon").height - Layout.alignment: Qt.AlignCenter - enabled: false // remove! - visible: false // replace packageInfo.XXXXXX - // TODO: waiting for materials card implementation - - Cura.ToolTip - { - tooltipText: "" // TODO - visible: parent.hovered - } - - UM.RecolorImage - { - anchors.fill: parent - - color: UM.Theme.getColor("primary") - source: UM.Theme.getIcon("CheckCircle") // TODO - } - - // onClicked: Qt.openUrlExternally( XXXXXX ) // TODO - } - - Label - { - id: packageVersionLabel - text: packageData.packageVersion - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - Layout.fillWidth: true - } - - Button - { - id: externalLinkButton - - // For some reason if i set padding, they don't match up. If i set all of them explicitly, it does work? - leftPadding: UM.Theme.getSize("narrow_margin").width - rightPadding: UM.Theme.getSize("narrow_margin").width - topPadding: UM.Theme.getSize("narrow_margin").width - bottomPadding: UM.Theme.getSize("narrow_margin").width - - Layout.preferredWidth: UM.Theme.getSize("card_tiny_icon").width + 2 * padding - Layout.preferredHeight: UM.Theme.getSize("card_tiny_icon").width + 2 * padding - contentItem: UM.RecolorImage - { - source: UM.Theme.getIcon("LinkExternal") - color: UM.Theme.getColor("icon") - implicitWidth: UM.Theme.getSize("card_tiny_icon").width - implicitHeight: UM.Theme.getSize("card_tiny_icon").height - } - - background: Rectangle - { - color: externalLinkButton.hovered ? UM.Theme.getColor("action_button_hovered"): "transparent" - radius: externalLinkButton.width / 2 - } - onClicked: Qt.openUrlExternally(packageData.authorInfoUrl) - } - } - - Item - { - id: shortDescription - Layout.preferredWidth: parent.width - Layout.fillHeight: true - - Label - { - id: descriptionLabel - width: parent.width - property real lastLineWidth: 0; //Store the width of the last line, to properly position the elision. - - text: packageData.description - textFormat: Text.PlainText //Must be plain text, or we won't get onLineLaidOut signals. Don't auto-detect! - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - maximumLineCount: 2 - wrapMode: Text.Wrap - elide: Text.ElideRight - visible: text !== "" - - onLineLaidOut: - { - if(truncated && line.isLast) - { - let max_line_width = parent.width - readMoreButton.width - fontMetrics.advanceWidth("… ") - 2 * UM.Theme.getSize("default_margin").width; - if(line.implicitWidth > max_line_width) - { - line.width = max_line_width; - } - else - { - line.width = line.implicitWidth - fontMetrics.advanceWidth("…"); //Truncate the ellipsis. We're adding this ourselves. - } - descriptionLabel.lastLineWidth = line.implicitWidth; - } - } - } - Label - { - id: tripleDotLabel - anchors.left: parent.left - anchors.leftMargin: descriptionLabel.lastLineWidth - anchors.bottom: descriptionLabel.bottom - - text: "… " - font: descriptionLabel.font - color: descriptionLabel.color - visible: descriptionLabel.truncated && descriptionLabel.text !== "" - } - Cura.TertiaryButton - { - id: readMoreButton - anchors.right: parent.right - anchors.bottom: parent.bottom - height: fontMetrics.height //Height of a single line. - - text: catalog.i18nc("@info", "Read more") - iconSource: UM.Theme.getIcon("LinkExternal") - - visible: descriptionLabel.truncated && descriptionLabel.text !== "" - enabled: visible - leftPadding: UM.Theme.getSize("default_margin").width - rightPadding: UM.Theme.getSize("wide_margin").width - textFont: descriptionLabel.font - isIconOnRightSide: true - - onClicked: Qt.openUrlExternally(packageData.packageInfoUrl) - } - } - - Row - { - id: downloadCount - Layout.preferredWidth: parent.width - Layout.fillHeight: true - - UM.RecolorImage - { - id: downloadsIcon - width: UM.Theme.getSize("card_tiny_icon").width - height: UM.Theme.getSize("card_tiny_icon").height - - visible: packageData.installationStatus !== "bundled" //Don't show download count for packages that are bundled. It'll usually be 0. - source: UM.Theme.getIcon("Download") - color: UM.Theme.getColor("text") - } - - Label - { - anchors.verticalCenter: downloadsIcon.verticalCenter - - visible: packageData.installationStatus !== "bundled" //Don't show download count for packages that are bundled. It'll usually be 0. - color: UM.Theme.getColor("text") - font: UM.Theme.getFont("default") - text: packageData.downloadCount - } - } - - // Author and action buttons. - RowLayout - { - id: authorAndActionButton - Layout.preferredWidth: parent.width - Layout.preferredHeight: childrenRect.height - - spacing: UM.Theme.getSize("narrow_margin").width - - Label - { - id: authorBy - Layout.alignment: Qt.AlignTop - - text: catalog.i18nc("@label", "By") - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - } - - Cura.TertiaryButton - { - Layout.fillWidth: true - Layout.preferredHeight: authorBy.height - Layout.alignment: Qt.AlignTop - - text: packageData.authorName - textFont: UM.Theme.getFont("default_bold") - textColor: UM.Theme.getColor("text") // override normal link color - leftPadding: 0 - rightPadding: 0 - iconSource: UM.Theme.getIcon("LinkExternal") - isIconOnRightSide: true - - onClicked: Qt.openUrlExternally(packageData.authorInfoUrl) - } - - Cura.SecondaryButton - { - id: disableButton - Layout.alignment: Qt.AlignTop - text: catalog.i18nc("@button", "Disable") - visible: false // not functional right now, also only when unfolding and required - } - - Cura.SecondaryButton - { - id: uninstallButton - Layout.alignment: Qt.AlignTop - text: catalog.i18nc("@button", "Uninstall") - visible: false // not functional right now, also only when unfolding and required - } - - Cura.PrimaryButton - { - id: installButton - Layout.alignment: Qt.AlignTop - text: catalog.i18nc("@button", "Update") // OR Download, if new! - visible: false // not functional right now, also only when unfolding and required - } - } - } - } - - Column - { - id: extendedDescription - width: parent.width - - padding: UM.Theme.getSize("default_margin").width - topPadding: 0 - spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent Label { - width: parent.width - parent.padding * 2 + id: descriptionLabel + width: parent.width + property real lastLineWidth: 0; //Store the width of the last line, to properly position the elision. - text: catalog.i18nc("@header", "Description") - font: UM.Theme.getFont("medium_bold") + text: packageData.description + textFormat: Text.PlainText //Must be plain text, or we won't get onLineLaidOut signals. Don't auto-detect! + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") - elide: Text.ElideRight - } - - Label - { - width: parent.width - parent.padding * 2 - - text: packageData.formattedDescription - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text") - linkColor: UM.Theme.getColor("text_link") + maximumLineCount: 2 wrapMode: Text.Wrap - textFormat: Text.RichText + elide: Text.ElideRight + visible: text !== "" - onLinkActivated: UM.UrlUtil.openUrl(link, ["http", "https"]) - } - - Column //Separate column to have no spacing between compatible printers. - { - id: compatiblePrinterColumn - width: parent.width - parent.padding * 2 - - visible: packageData.packageType === "material" - spacing: 0 - - Label + onLineLaidOut: { - width: parent.width - - text: catalog.i18nc("@header", "Compatible printers") - font: UM.Theme.getFont("medium_bold") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } - - Repeater - { - model: packageData.compatiblePrinters - - Label + if(truncated && line.isLast) { - width: compatiblePrinterColumn.width - - text: modelData - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text") - elide: Text.ElideRight + let max_line_width = parent.width - readMoreButton.width - fontMetrics.advanceWidth("… ") - 2 * UM.Theme.getSize("default_margin").width; + if(line.implicitWidth > max_line_width) + { + line.width = max_line_width; + } + else + { + line.width = line.implicitWidth - fontMetrics.advanceWidth("…"); //Truncate the ellipsis. We're adding this ourselves. + } + descriptionLabel.lastLineWidth = line.implicitWidth; } } - - Label - { - width: parent.width - - visible: packageData.compatiblePrinters.length == 0 - text: "(" + catalog.i18nc("@info", "No compatibility information") + ")" - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } } - - Column + Label { - id: compatibleSupportMaterialColumn - width: parent.width - parent.padding * 2 + id: tripleDotLabel + anchors.left: parent.left + anchors.leftMargin: descriptionLabel.lastLineWidth + anchors.bottom: descriptionLabel.bottom - visible: packageData.packageType === "material" - spacing: 0 - - Label - { - width: parent.width - - text: catalog.i18nc("@header", "Compatible support materials") - font: UM.Theme.getFont("medium_bold") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } - - Repeater - { - model: packageData.compatibleSupportMaterials - - Label - { - width: compatibleSupportMaterialColumn.width - - text: modelData - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } - } - - Label - { - width: parent.width - - visible: packageData.compatibleSupportMaterials.length == 0 - text: "(" + catalog.i18nc("@info No materials", "None") + ")" - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } + text: "… " + font: descriptionLabel.font + color: descriptionLabel.color + visible: descriptionLabel.truncated && descriptionLabel.text !== "" } - - Column + Cura.TertiaryButton { - width: parent.width - parent.padding * 2 + id: readMoreButton + anchors.right: parent.right + anchors.bottom: descriptionLabel.bottom + height: fontMetrics.height //Height of a single line. - visible: packageData.packageType === "material" - spacing: 0 + text: catalog.i18nc("@info", "Read more") + iconSource: UM.Theme.getIcon("LinkExternal") - Label - { - width: parent.width + visible: descriptionLabel.truncated && descriptionLabel.text !== "" + enabled: visible + leftPadding: UM.Theme.getSize("default_margin").width + rightPadding: UM.Theme.getSize("wide_margin").width + textFont: descriptionLabel.font + isIconOnRightSide: true - text: catalog.i18nc("@header", "Compatible with Material Station") - font: UM.Theme.getFont("medium_bold") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } - - Label - { - width: parent.width - - text: packageData.isCompatibleMaterialStation ? catalog.i18nc("@info", "Yes") : catalog.i18nc("@info", "No") - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } - } - - Column - { - width: parent.width - parent.padding * 2 - - visible: packageData.packageType === "material" - spacing: 0 - - Label - { - width: parent.width - - text: catalog.i18nc("@header", "Optimized for Air Manager") - font: UM.Theme.getFont("medium_bold") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } - - Label - { - width: parent.width - - text: packageData.isCompatibleAirManager ? catalog.i18nc("@info", "Yes") : catalog.i18nc("@info", "No") - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text") - elide: Text.ElideRight - } - } - - Row - { - id: externalButtonRow - anchors.horizontalCenter: parent.horizontalCenter - - spacing: UM.Theme.getSize("narrow_margin").width - - Cura.SecondaryButton - { - text: packageData.packageType === "plugin" ? catalog.i18nc("@button", "Visit plug-in website") : catalog.i18nc("@button", "Website") - iconSource: UM.Theme.getIcon("Globe") - outlineColor: "transparent" - onClicked: Qt.openUrlExternally(packageData.packageInfoUrl) - } - - Cura.SecondaryButton - { - visible: packageData.packageType === "material" - text: catalog.i18nc("@button", "Buy spool") - iconSource: UM.Theme.getIcon("ShoppingCart") - outlineColor: "transparent" - onClicked: Qt.openUrlExternally(packageData.whereToBuy) - } - - Cura.SecondaryButton - { - visible: packageData.packageType === "material" - text: catalog.i18nc("@button", "Safety datasheet") - iconSource: UM.Theme.getIcon("Warning") - outlineColor: "transparent" - onClicked: Qt.openUrlExternally(packageData.safetyDataSheet) - } - - Cura.SecondaryButton - { - visible: packageData.packageType === "material" - text: catalog.i18nc("@button", "Technical datasheet") - iconSource: UM.Theme.getIcon("DocumentFilled") - outlineColor: "transparent" - onClicked: Qt.openUrlExternally(packageData.technicalDataSheet) - } + onClicked: Qt.openUrlExternally(packageData.packageInfoUrl) } } } diff --git a/plugins/Marketplace/resources/qml/PackageCardHeader.qml b/plugins/Marketplace/resources/qml/PackageCardHeader.qml new file mode 100644 index 0000000000..0bf93fc67c --- /dev/null +++ b/plugins/Marketplace/resources/qml/PackageCardHeader.qml @@ -0,0 +1,213 @@ +// 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.1 + +import UM 1.6 as UM +import Cura 1.6 as Cura + +// As both the PackageCard and Package contain similar components; a package icon, title, author bar. These components +// are combined into the reusable "PackageCardHeader" component +Item +{ + default property alias contents: contentItem.children; + + property var packageData + property bool showManageButtons: false + + width: parent.width + height: UM.Theme.getSize("card").height + + // card icon + Image + { + id: packageItem + anchors + { + top: parent.top + left: parent.left + margins: UM.Theme.getSize("default_margin").width + } + width: UM.Theme.getSize("card_icon").width + height: width + + source: packageData.iconUrl != "" ? packageData.iconUrl : "../images/placeholder.svg" + } + + ColumnLayout + { + anchors + { + left: packageItem.right + leftMargin: UM.Theme.getSize("default_margin").width + right: parent.right + rightMargin: UM.Theme.getSize("default_margin").width + top: parent.top + topMargin: UM.Theme.getSize("narrow_margin").height + } + height: packageItem.height + packageItem.anchors.margins * 2 + + // Title row. + RowLayout + { + id: titleBar + Layout.preferredWidth: parent.width + Layout.preferredHeight: childrenRect.height + + Label + { + text: packageData.displayName + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("text") + verticalAlignment: Text.AlignTop + } + VerifiedIcon + { + enabled: packageData.isCheckedByUltimaker + visible: packageData.isCheckedByUltimaker + } + + Label + { + id: packageVersionLabel + text: packageData.packageVersion + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + Layout.fillWidth: true + } + + Button + { + id: externalLinkButton + + // For some reason if i set padding, they don't match up. If i set all of them explicitly, it does work? + leftPadding: UM.Theme.getSize("narrow_margin").width + rightPadding: UM.Theme.getSize("narrow_margin").width + topPadding: UM.Theme.getSize("narrow_margin").width + bottomPadding: UM.Theme.getSize("narrow_margin").width + + Layout.preferredWidth: UM.Theme.getSize("card_tiny_icon").width + 2 * padding + Layout.preferredHeight: UM.Theme.getSize("card_tiny_icon").width + 2 * padding + contentItem: UM.RecolorImage + { + source: UM.Theme.getIcon("LinkExternal") + color: UM.Theme.getColor("icon") + implicitWidth: UM.Theme.getSize("card_tiny_icon").width + implicitHeight: UM.Theme.getSize("card_tiny_icon").height + } + + background: Rectangle + { + color: externalLinkButton.hovered ? UM.Theme.getColor("action_button_hovered"): "transparent" + radius: externalLinkButton.width / 2 + } + onClicked: Qt.openUrlExternally(packageData.authorInfoUrl) + } + } + + // When a package Card companent is created and children are provided to it they are rendered here + Item { + id: contentItem + Layout.fillHeight: true + Layout.preferredWidth: parent.width + } + + // Author and action buttons. + RowLayout + { + id: authorAndActionButton + Layout.preferredWidth: parent.width + Layout.preferredHeight: childrenRect.height + + spacing: UM.Theme.getSize("narrow_margin").width + + // label "By" + Label + { + id: authorBy + Layout.alignment: Qt.AlignCenter + + text: catalog.i18nc("@label", "By") + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + } + + // clickable author name + Cura.TertiaryButton + { + Layout.fillWidth: true + Layout.preferredHeight: authorBy.height + Layout.alignment: Qt.AlignCenter + + text: packageData.authorName + textFont: UM.Theme.getFont("default_bold") + textColor: UM.Theme.getColor("text") // override normal link color + leftPadding: 0 + rightPadding: 0 + iconSource: UM.Theme.getIcon("LinkExternal") + isIconOnRightSide: true + + onClicked: Qt.openUrlExternally(packageData.authorInfoUrl) + } + + ManageButton + { + id: enableManageButton + visible: showManageButtons && packageData.isInstalled && !packageData.isToBeInstalled && packageData.packageType != "material" + enabled: !packageData.busy + + button_style: !packageData.isActive + Layout.alignment: Qt.AlignTop + + text: button_style ? catalog.i18nc("@button", "Enable") : catalog.i18nc("@button", "Disable") + + onClicked: packageData.isActive ? packageData.disable(): packageData.enable() + } + + ManageButton + { + id: installManageButton + visible: showManageButtons && (packageData.canDowngrade || !packageData.isBundled) + enabled: !packageData.busy + busy: packageData.busy + button_style: !(packageData.isInstalled || packageData.isToBeInstalled) + Layout.alignment: Qt.AlignTop + + text: + { + if (packageData.canDowngrade) + { + if (busy) { return catalog.i18nc("@button", "Downgrading..."); } + else { return catalog.i18nc("@button", "Downgrade"); } + } + if (!(packageData.isInstalled || packageData.isToBeInstalled)) + { + if (busy) { return catalog.i18nc("@button", "Installing..."); } + else { return catalog.i18nc("@button", "Install"); } + } + else + { + return catalog.i18nc("@button", "Uninstall"); + } + } + + onClicked: packageData.isInstalled || packageData.isToBeInstalled ? packageData.uninstall(): packageData.install() + } + + ManageButton + { + id: updateManageButton + visible: showManageButtons && packageData.canUpdate + enabled: !packageData.busy + busy: packageData.busy + Layout.alignment: Qt.AlignTop + + text: busy ? catalog.i18nc("@button", "Updating..."): catalog.i18nc("@button", "Update") + + onClicked: packageData.update() + } + } + } +} diff --git a/plugins/Marketplace/resources/qml/PackageDetails.qml b/plugins/Marketplace/resources/qml/PackageDetails.qml index fdf1c8f92c..2599c7f28c 100644 --- a/plugins/Marketplace/resources/qml/PackageDetails.qml +++ b/plugins/Marketplace/resources/qml/PackageDetails.qml @@ -74,11 +74,11 @@ Item clip: true //Need to clip, not for the bottom (which is off the window) but for the top (which would overlap the header). ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - contentHeight: expandedPackageCard.height + UM.Theme.getSize("default_margin").height * 2 + contentHeight: packagePage.height + UM.Theme.getSize("default_margin").height * 2 - PackageCard + PackagePage { - id: expandedPackageCard + id: packagePage anchors { left: parent.left @@ -90,7 +90,6 @@ Item } packageData: detailPage.packageData - expanded: true } } } diff --git a/plugins/Marketplace/resources/qml/PackagePage.qml b/plugins/Marketplace/resources/qml/PackagePage.qml new file mode 100644 index 0000000000..21c400fff2 --- /dev/null +++ b/plugins/Marketplace/resources/qml/PackagePage.qml @@ -0,0 +1,295 @@ +// 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.1 + +import UM 1.6 as UM +import Cura 1.6 as Cura + +Rectangle +{ + id: root + property alias packageData: packageCardHeader.packageData + + height: childrenRect.height + color: UM.Theme.getColor("main_background") + radius: UM.Theme.getSize("default_radius").width + + Column + { + width: parent.width + + spacing: 0 + + Item + { + width: parent.width + height: UM.Theme.getSize("card").height + + PackageCardHeader + { + id: packageCardHeader + showManageButtons: true + + anchors.fill: parent + + Row + { + id: downloadCount + Layout.preferredWidth: parent.width + Layout.fillHeight: true + + UM.RecolorImage + { + id: downloadsIcon + width: UM.Theme.getSize("card_tiny_icon").width + height: UM.Theme.getSize("card_tiny_icon").height + + source: UM.Theme.getIcon("Download") + color: UM.Theme.getColor("text") + } + + Label + { + anchors.verticalCenter: downloadsIcon.verticalCenter + + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("default") + text: packageData.downloadCount + } + } + } + } + + Column + { + id: extendedDescription + width: parent.width + + padding: UM.Theme.getSize("default_margin").width + topPadding: 0 + spacing: UM.Theme.getSize("default_margin").height + + Label + { + width: parent.width - parent.padding * 2 + + text: catalog.i18nc("@header", "Description") + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + + Label + { + width: parent.width - parent.padding * 2 + + text: packageData.formattedDescription + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + linkColor: UM.Theme.getColor("text_link") + wrapMode: Text.Wrap + textFormat: Text.RichText + + onLinkActivated: UM.UrlUtil.openUrl(link, ["http", "https"]) + } + + Column //Separate column to have no spacing between compatible printers. + { + id: compatiblePrinterColumn + width: parent.width - parent.padding * 2 + + visible: packageData.packageType === "material" + spacing: 0 + + Label + { + width: parent.width + + text: catalog.i18nc("@header", "Compatible printers") + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + + Repeater + { + model: packageData.compatiblePrinters + + Label + { + width: compatiblePrinterColumn.width + + text: modelData + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + } + + Label + { + width: parent.width + + visible: packageData.compatiblePrinters.length == 0 + text: "(" + catalog.i18nc("@info", "No compatibility information") + ")" + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + } + + Column + { + id: compatibleSupportMaterialColumn + width: parent.width - parent.padding * 2 + + visible: packageData.packageType === "material" + spacing: 0 + + Label + { + width: parent.width + + text: catalog.i18nc("@header", "Compatible support materials") + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + + Repeater + { + model: packageData.compatibleSupportMaterials + + Label + { + width: compatibleSupportMaterialColumn.width + + text: modelData + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + } + + Label + { + width: parent.width + + visible: packageData.compatibleSupportMaterials.length == 0 + text: "(" + catalog.i18nc("@info No materials", "None") + ")" + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + } + + Column + { + width: parent.width - parent.padding * 2 + + visible: packageData.packageType === "material" + spacing: 0 + + Label + { + width: parent.width + + text: catalog.i18nc("@header", "Compatible with Material Station") + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + + Label + { + width: parent.width + + text: packageData.isCompatibleMaterialStation ? catalog.i18nc("@info", "Yes") : catalog.i18nc("@info", "No") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + } + + Column + { + width: parent.width - parent.padding * 2 + + visible: packageData.packageType === "material" + spacing: 0 + + Label + { + width: parent.width + + text: catalog.i18nc("@header", "Optimized for Air Manager") + font: UM.Theme.getFont("medium_bold") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + + Label + { + width: parent.width + + text: packageData.isCompatibleAirManager ? catalog.i18nc("@info", "Yes") : catalog.i18nc("@info", "No") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + elide: Text.ElideRight + } + } + + Row + { + id: externalButtonRow + anchors.horizontalCenter: parent.horizontalCenter + + spacing: UM.Theme.getSize("narrow_margin").width + + Cura.SecondaryButton + { + text: packageData.packageType === "plugin" ? catalog.i18nc("@button", "Visit plug-in website") : catalog.i18nc("@button", "Website") + iconSource: UM.Theme.getIcon("Globe") + outlineColor: "transparent" + onClicked: Qt.openUrlExternally(packageData.packageInfoUrl) + } + + Cura.SecondaryButton + { + visible: packageData.packageType === "material" + text: catalog.i18nc("@button", "Buy spool") + iconSource: UM.Theme.getIcon("ShoppingCart") + outlineColor: "transparent" + onClicked: Qt.openUrlExternally(packageData.whereToBuy) + } + + Cura.SecondaryButton + { + visible: packageData.packageType === "material" + text: catalog.i18nc("@button", "Safety datasheet") + iconSource: UM.Theme.getIcon("Warning") + outlineColor: "transparent" + onClicked: Qt.openUrlExternally(packageData.safetyDataSheet) + } + + Cura.SecondaryButton + { + visible: packageData.packageType === "material" + text: catalog.i18nc("@button", "Technical datasheet") + iconSource: UM.Theme.getIcon("DocumentFilled") + outlineColor: "transparent" + onClicked: Qt.openUrlExternally(packageData.technicalDataSheet) + } + } + } + } + + FontMetrics + { + id: fontMetrics + font: UM.Theme.getFont("default") + } +} diff --git a/plugins/Marketplace/resources/qml/Packages.qml b/plugins/Marketplace/resources/qml/Packages.qml index 2f92b30391..194c90c248 100644 --- a/plugins/Marketplace/resources/qml/Packages.qml +++ b/plugins/Marketplace/resources/qml/Packages.qml @@ -19,11 +19,12 @@ ListView property string bannerText property string bannerReadMoreUrl property var onRemoveBanner + property bool packagesManageableInListView clip: true Component.onCompleted: model.updatePackages() - Component.onDestruction: model.abortUpdating() + Component.onDestruction: model.cleanUpAPIRequest() spacing: UM.Theme.getSize("default_margin").height @@ -35,15 +36,13 @@ ListView color: UM.Theme.getColor("detail_background") - required property string section - Label { id: sectionHeaderText anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - text: parent.section + text: section font: UM.Theme.getFont("large") color: UM.Theme.getColor("text") } @@ -82,6 +81,7 @@ ListView PackageCard { + manageableInListView: packages.packagesManageableInListView packageData: model.package width: parent.width - UM.Theme.getSize("default_margin").width - UM.Theme.getSize("narrow_margin").width color: cardMouseArea.containsMouse ? UM.Theme.getColor("action_button_hovered") : UM.Theme.getColor("main_background") @@ -230,4 +230,3 @@ ListView } } } - diff --git a/plugins/Marketplace/resources/qml/Plugins.qml b/plugins/Marketplace/resources/qml/Plugins.qml index 538afc827a..3cfa92d134 100644 --- a/plugins/Marketplace/resources/qml/Plugins.qml +++ b/plugins/Marketplace/resources/qml/Plugins.qml @@ -17,6 +17,7 @@ Packages bannerVisible = false; } searchInBrowserUrl: "https://marketplace.ultimaker.com/app/cura/plugins?utm_source=cura&utm_medium=software&utm_campaign=marketplace-search-plugins-browser" + packagesManageableInListView: false model: Marketplace.RemotePackageList { diff --git a/plugins/Marketplace/resources/qml/VerifiedIcon.qml b/plugins/Marketplace/resources/qml/VerifiedIcon.qml new file mode 100644 index 0000000000..30ef3080a0 --- /dev/null +++ b/plugins/Marketplace/resources/qml/VerifiedIcon.qml @@ -0,0 +1,45 @@ +// 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.1 + +import UM 1.6 as UM +import Cura 1.6 as Cura +Control +{ + implicitWidth: UM.Theme.getSize("card_tiny_icon").width + implicitHeight: UM.Theme.getSize("card_tiny_icon").height + + Cura.ToolTip + { + tooltipText: + { + switch(packageData.packageType) + { + case "plugin": return catalog.i18nc("@info", "Ultimaker Verified Plug-in"); + case "material": return catalog.i18nc("@info", "Ultimaker Certified Material"); + default: return catalog.i18nc("@info", "Ultimaker Verified Package"); + } + } + visible: parent.hovered + targetPoint: Qt.point(0, Math.round(parent.y + parent.height / 4)) + } + + Rectangle + { + anchors.fill: parent + color: UM.Theme.getColor("action_button_hovered") + radius: width + UM.RecolorImage + { + anchors.fill: parent + color: UM.Theme.getColor("primary") + source: packageData.packageType == "plugin" ? UM.Theme.getIcon("CheckCircle") : UM.Theme.getIcon("Certified") + } + } + + //NOTE: Can we link to something here? (Probably a static link explaining what verified is): + // onClicked: Qt.openUrlExternally( XXXXXX ) +} \ No newline at end of file diff --git a/plugins/Toolbox/src/CloudSync/CloudApiClient.py b/plugins/Toolbox/src/CloudSync/CloudApiClient.py index 21eb1bdbd2..9543ec012e 100644 --- a/plugins/Toolbox/src/CloudSync/CloudApiClient.py +++ b/plugins/Toolbox/src/CloudSync/CloudApiClient.py @@ -38,7 +38,7 @@ class CloudApiClient: def _subscribe(self, package_id: str) -> None: """You probably don't want to use this directly. All installed packages will be automatically subscribed.""" - Logger.debug("Subscribing to {}", package_id) + Logger.debug("Subscribing to using the Old Toolbox {}", package_id) data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version) HttpRequestManager.getInstance().put( url = CloudApiModel.api_url_user_packages, diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index e525a88d89..e300d0ff34 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -1,5 +1,5 @@ -# Copyright (c) 2021 Ultimaker B.V. -# Toolbox is released under the terms of the LGPLv3 or higher. +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. import json import os @@ -634,8 +634,8 @@ class Toolbox(QObject, Extension): self._models[request_type].setFilter({"tags": "generic"}) elif request_type == "updates": # Tell the package manager that there's a new set of updates available. - packages = set([pkg["package_id"] for pkg in self._server_response_data[request_type]]) - self._package_manager.setPackagesWithUpdate(packages) + packages = self._server_response_data[request_type] + self._package_manager.setPackagesWithUpdate({p['package_id'] for p in packages}) self.metadataChanged.emit() diff --git a/resources/themes/cura-light/icons/default/Spinner.svg b/resources/themes/cura-light/icons/default/Spinner.svg new file mode 100644 index 0000000000..22a8f4dfd9 --- /dev/null +++ b/resources/themes/cura-light/icons/default/Spinner.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/themes/cura-light/icons/high/Certificate.svg b/resources/themes/cura-light/icons/high/Certificate.svg new file mode 100644 index 0000000000..b588bddd8b --- /dev/null +++ b/resources/themes/cura-light/icons/high/Certificate.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/themes/cura-light/theme.json b/resources/themes/cura-light/theme.json index 38d69df808..8e9db0e9fe 100644 --- a/resources/themes/cura-light/theme.json +++ b/resources/themes/cura-light/theme.json @@ -179,7 +179,7 @@ "lining": [192, 193, 194, 255], "viewport_overlay": [246, 246, 246, 255], - "primary": [50, 130, 255, 255], + "primary": [25, 110, 240, 255], "primary_shadow": [64, 47, 205, 255], "primary_hover": [48, 182, 231, 255], "primary_text": [255, 255, 255, 255], @@ -554,7 +554,7 @@ "standard_list_lineheight": [1.5, 1.5], "standard_arrow": [1.0, 1.0], - "card": [25.0, 8.75], + "card": [25.0, 10], "card_icon": [6.0, 6.0], "card_tiny_icon": [1.5, 1.5], @@ -686,6 +686,8 @@ "welcome_wizard_content_image_big": [18, 15], "welcome_wizard_cloud_content_image": [4, 4], - "banner_icon_size": [2.0, 2.0] + "banner_icon_size": [2.0, 2.0], + + "marketplace_large_icon": [4.0, 4.0] } }