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]
}
}