diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py
new file mode 100644
index 0000000000..6acbaa8500
--- /dev/null
+++ b/plugins/Marketplace/LocalPackageList.py
@@ -0,0 +1,93 @@
+# Copyright (c) 2021 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from typing import Any, Dict, Generator, List, Optional, TYPE_CHECKING
+from PyQt5.QtCore import pyqtSlot, QObject
+
+if TYPE_CHECKING:
+ from PyQt5.QtCore import QObject
+
+from UM.i18n import i18nCatalog
+
+from cura.CuraApplication import CuraApplication
+
+from .PackageList import PackageList
+from .PackageModel import PackageModel # The contents of this list.
+
+catalog = i18nCatalog("cura")
+
+
+class LocalPackageList(PackageList):
+ PACKAGE_SECTION_HEADER = {
+ "installed":
+ {
+ "plugin": catalog.i18nc("@label:property", "Installed Plugins"),
+ "material": catalog.i18nc("@label:property", "Installed Materials")
+ },
+ "bundled":
+ {
+ "plugin": catalog.i18nc("@label:property", "Bundled Plugins"),
+ "material": catalog.i18nc("@label:property", "Bundled Materials")
+ }
+ } # The section headers to be used for the different package categories
+
+ def __init__(self, parent: Optional["QObject"] = None) -> None:
+ super().__init__(parent)
+ self._manager = CuraApplication.getInstance().getPackageManager()
+ self._has_footer = False
+
+ @pyqtSlot()
+ def updatePackages(self) -> None:
+ """Update the list with local packages, these are materials or plugin, either bundled or user installed. The list
+ will also contain **to be removed** or **to be installed** packages since the user might still want to interact
+ with these.
+ """
+ self.setErrorMessage("") # Clear any previous errors.
+ self.setIsLoading(True)
+ self._getLocalPackages()
+ self.setIsLoading(False)
+ self.setHasMore(False) # All packages should have been loaded at this time
+
+ def _getLocalPackages(self) -> None:
+ """ Obtain the local packages.
+
+ The list is sorted per category as in the order of the PACKAGE_SECTION_HEADER dictionary, whereas the packages
+ for the sections are sorted alphabetically on the display name. These sorted sections are then added to the items
+ """
+ package_info = list(self._allPackageInfo())
+ sorted_sections: List[Dict[str, PackageModel]] = []
+ for section in self._getSections():
+ packages = filter(lambda p: p.sectionTitle == section, package_info)
+ sorted_sections.extend([{"package": p} for p in sorted(packages, key = lambda p: p.displayName)])
+ self.setItems(sorted_sections)
+
+ def _getSections(self) -> Generator[str, None, None]:
+ """ Flatten and order the PACKAGE_SECTION_HEADER such that it can be used in obtaining the packages in the
+ correct order"""
+ for package_type in self.PACKAGE_SECTION_HEADER.values():
+ for section in package_type.values():
+ yield section
+
+ def _allPackageInfo(self) -> Generator[PackageModel, None, None]:
+ """ A generator which returns a unordered list of all the PackageModels"""
+
+ # Get all the installed packages, add a section_title depending on package_type and user installed
+ for packages in self._manager.getAllInstalledPackagesInfo().values():
+ for package_info in packages:
+ yield self._makePackageModel(package_info)
+
+ # Get all to be removed package_info's. These packages are still used in the current session so the user might
+ # still want to interact with these.
+ for package_data in self._manager.getPackagesToRemove().values():
+ yield self._makePackageModel(package_data["package_info"])
+
+ # Get all to be installed package_info's. Since the user might want to interact with these
+ for package_data in self._manager.getPackagesToInstall().values():
+ yield self._makePackageModel(package_data["package_info"])
+
+ def _makePackageModel(self, package_info: Dict[str, Any]) -> PackageModel:
+ """ Create a PackageModel from the package_info and determine its section_title"""
+ bundled_or_installed = "installed" if self._manager.isUserInstalledPackage(package_info["package_id"]) else "bundled"
+ package_type = package_info["package_type"]
+ section_title = self.PACKAGE_SECTION_HEADER[bundled_or_installed][package_type]
+ return PackageModel(package_info, section_title = section_title, parent = self)
diff --git a/plugins/Marketplace/Marketplace.py b/plugins/Marketplace/Marketplace.py
index b3f573f792..18d80d6e68 100644
--- a/plugins/Marketplace/Marketplace.py
+++ b/plugins/Marketplace/Marketplace.py
@@ -9,11 +9,12 @@ from typing import Optional, TYPE_CHECKING
from cura.ApplicationMetadata import CuraSDKVersion
from cura.CuraApplication import CuraApplication # Creating QML objects and managing packages.
from cura.UltimakerCloud import UltimakerCloudConstants
+
from UM.Extension import Extension # We are implementing the main object of an extension here.
-from UM.Logger import Logger
from UM.PluginRegistry import PluginRegistry # To find out where we are stored (the proper way).
-from .PackageList import PackageList # To register this type with QML.
+from .RemotePackageList import RemotePackageList # To register this type with QML.
+from .LocalPackageList import LocalPackageList # To register this type with QML.
if TYPE_CHECKING:
from PyQt5.QtCore import QObject
@@ -31,7 +32,8 @@ class Marketplace(Extension):
super().__init__()
self._window: Optional["QObject"] = None # If the window has been loaded yet, it'll be cached in here.
- qmlRegisterType(PackageList, "Marketplace", 1, 0, "PackageList")
+ qmlRegisterType(RemotePackageList, "Marketplace", 1, 0, "RemotePackageList")
+ qmlRegisterType(LocalPackageList, "Marketplace", 1, 0, "LocalPackageList")
@pyqtSlot()
def show(self) -> None:
diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py
index 4ebbe8d349..8171d168f2 100644
--- a/plugins/Marketplace/PackageList.py
+++ b/plugins/Marketplace/PackageList.py
@@ -2,19 +2,10 @@
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt
-from PyQt5.QtNetwork import QNetworkReply
from typing import Optional, TYPE_CHECKING
-from cura.CuraApplication import CuraApplication
-from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
from UM.i18n import i18nCatalog
-from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
-from UM.TaskManagement.HttpRequestManager import HttpRequestManager, HttpRequestData # To request the package list from the API.
-from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope # To request JSON responses from the API.
-
-from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports.
-from .PackageModel import PackageModel # The contents of this list.
if TYPE_CHECKING:
from PyQt5.QtCore import QObject
@@ -23,164 +14,80 @@ catalog = i18nCatalog("cura")
class PackageList(ListModel):
+ """ A List model for Packages, this class serves as parent class for more detailed implementations.
+ such as Packages obtained from Remote or Local source
"""
- Represents a list of packages to be displayed in the interface.
-
- The list can be filtered (e.g. on package type, materials vs. plug-ins) and
- paginated.
- """
-
PackageRole = Qt.UserRole + 1
- ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once.
-
- def __init__(self, parent: "QObject" = None) -> None:
+ def __init__(self, parent: Optional["QObject"] = None) -> None:
super().__init__(parent)
-
- self._ongoing_request: Optional[HttpRequestData] = None
- self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
self._error_message = ""
-
- self._package_type_filter = ""
- self._request_url = self._initialRequestUrl()
-
self.addRoleName(self.PackageRole, "package")
-
- def __del__(self) -> None:
- """
- When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++
- object.
- """
- self.abortRequest()
+ self._is_loading = False
+ self._has_more = False
+ self._has_footer = True
@pyqtSlot()
- def request(self) -> None:
- """
- Make a request for the first paginated page of packages.
-
- When the request is done, the list will get updated with the new package models.
- """
- self.setErrorMessage("") # Clear any previous errors.
-
- http = HttpRequestManager.getInstance()
- self._ongoing_request = http.get(
- self._request_url,
- scope = self._scope,
- callback = self._parseResponse,
- error_callback = self._onError
- )
- self.isLoadingChanged.emit()
+ def updatePackages(self) -> None:
+ """ A Qt slot which will update the List from a source. Actual implementation should be done in the child class"""
+ pass
@pyqtSlot()
- def abortRequest(self) -> None:
- HttpRequestManager.getInstance().abortRequest(self._ongoing_request)
- self._ongoing_request = None
+ def abortUpdating(self) -> None:
+ """ A Qt slot which allows the update process to be aborted. Override this for child classes with async/callback
+ updatePackges methods"""
+ pass
def reset(self) -> None:
+ """ Resets and clears the list"""
self.clear()
- self._request_url = self._initialRequestUrl()
- isLoadingChanged = pyqtSignal()
+ isLoadingChanged = pyqtSignal()
- @pyqtProperty(bool, notify = isLoadingChanged)
+ def setIsLoading(self, value: bool) -> None:
+ if self._is_loading != value:
+ self._is_loading = value
+ self.isLoadingChanged.emit()
+
+ @pyqtProperty(bool, fset = setIsLoading, notify = isLoadingChanged)
def isLoading(self) -> bool:
+ """ Indicating if the the packages are loading
+ :return" ``True`` if the list is being obtained, otherwise ``False``
"""
- Gives whether the list is currently loading the first page or loading more pages.
- :return: ``True`` if the list is downloading, or ``False`` if not downloading.
- """
- return self._ongoing_request is not None
+ return self._is_loading
hasMoreChanged = pyqtSignal()
- @pyqtProperty(bool, notify = hasMoreChanged)
+ def setHasMore(self, value: bool) -> None:
+ if self._has_more != value:
+ self._has_more = value
+ self.hasMoreChanged.emit()
+
+ @pyqtProperty(bool, fset = setHasMore, notify = hasMoreChanged)
def hasMore(self) -> bool:
+ """ Indicating if there are more packages available to load.
+ :return: ``True`` if there are more packages to load, or ``False``.
"""
- Returns whether there are more packages to load.
- :return: ``True`` if there are more packages to load, or ``False`` if we've reached the last page of the
- pagination.
- """
- return self._request_url != ""
+ return self._has_more
- packageTypeFilterChanged = pyqtSignal()
-
- def setPackageTypeFilter(self, new_filter: str) -> None:
- if new_filter != self._package_type_filter:
- self._package_type_filter = new_filter
- self.reset()
- self.packageTypeFilterChanged.emit()
-
- @pyqtProperty(str, notify = packageTypeFilterChanged, fset = setPackageTypeFilter)
- def packageTypeFilter(self) -> str:
- """
- Get the package type this package list is filtering on, like ``plugin`` or ``material``.
- :return: The package type this list is filtering on.
- """
- return self._package_type_filter
+ errorMessageChanged = pyqtSignal()
def setErrorMessage(self, error_message: str) -> None:
if self._error_message != error_message:
self._error_message = error_message
self.errorMessageChanged.emit()
- errorMessageChanged = pyqtSignal()
-
@pyqtProperty(str, notify = errorMessageChanged, fset = setErrorMessage)
def errorMessage(self) -> str:
- """
- If an error occurred getting the list of packages, an error message will be held here.
+ """ If an error occurred getting the list of packages, an error message will be held here.
If no error occurred (yet), this will be an empty string.
:return: An error message, if any, or an empty string if everything went okay.
"""
return self._error_message
- def _initialRequestUrl(self) -> str:
- """
- Get the URL to request the first paginated page with.
- :return: A URL to request.
- """
- if self._package_type_filter != "":
- return f"{Marketplace.PACKAGES_URL}?package_type={self._package_type_filter}&limit={self.ITEMS_PER_PAGE}"
- return f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}"
-
- def _parseResponse(self, reply: "QNetworkReply") -> None:
- """
- Parse the response from the package list API request.
-
- This converts that response into PackageModels, and triggers the ListModel to update.
- :param reply: A reply containing information about a number of packages.
- """
- response_data = HttpRequestManager.readJSON(reply)
- if "data" not in response_data or "links" not in response_data:
- Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}")
- self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response."))
- return
-
- for package_data in response_data["data"]:
- try:
- package = PackageModel(package_data, parent = self)
- self.appendItem({"package": package}) # Add it to this list model.
- except RuntimeError:
- # I've tried setting the ownership of this object to not qml, but unfortunately that didn't prevent
- # the issue that the wrapped C++ object was deleted when it was still parsing the response
- return
-
- self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page.
- self.hasMoreChanged.emit()
- self._ongoing_request = None
- self.isLoadingChanged.emit()
-
- def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None:
- """
- Handles networking and server errors when requesting the list of packages.
- :param reply: The reply with packages. This will most likely be incomplete and should be ignored.
- :param error: The error status of the request.
- """
- if error == QNetworkReply.NetworkError.OperationCanceledError:
- Logger.debug("Cancelled request for packages.")
- self._ongoing_request = None
- return # Don't show an error about this to the user.
- Logger.error("Could not reach Marketplace server.")
- self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace."))
- self._ongoing_request = None
- self.isLoadingChanged.emit()
+ @pyqtProperty(bool, constant = True)
+ def hasFooter(self) -> bool:
+ """ Indicating if the PackageList should have a Footer visible. For paginated PackageLists
+ :return: ``True`` if a Footer should be displayed in the ListView, e.q.: paginated lists, ``False`` Otherwise"""
+ return self._has_footer
diff --git a/plugins/Marketplace/PackageModel.py b/plugins/Marketplace/PackageModel.py
index 5ca25b370b..e57a402fd6 100644
--- a/plugins/Marketplace/PackageModel.py
+++ b/plugins/Marketplace/PackageModel.py
@@ -2,7 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, QObject
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from UM.i18n import i18nCatalog # To translate placeholder names if data is not present.
@@ -16,15 +16,17 @@ class PackageModel(QObject):
QML. The model can also be constructed directly from a response received by the API.
"""
- def __init__(self, package_data: Dict[str, Any], parent: QObject = None) -> None:
+ def __init__(self, package_data: Dict[str, Any], section_title: Optional[str] = None, parent: Optional[QObject] = None) -> None:
"""
Constructs a new model for a single package.
:param package_data: The data received from the Marketplace API about the package to create.
+ :param section_title: If the packages are to be categorized per section provide the section_title
:param parent: The parent QML object that controls the lifetime of this model (normally a PackageList).
"""
super().__init__(parent)
self._package_id = package_data.get("package_id", "UnknownPackageId")
self._display_name = package_data.get("display_name", catalog.i18nc("@label:property", "Unknown Package"))
+ self._section_title = section_title
@pyqtProperty(str, constant = True)
def packageId(self) -> str:
@@ -33,3 +35,7 @@ class PackageModel(QObject):
@pyqtProperty(str, constant = True)
def displayName(self) -> str:
return self._display_name
+
+ @pyqtProperty(str, constant = True)
+ def sectionTitle(self) -> Optional[str]:
+ return self._section_title
diff --git a/plugins/Marketplace/RemotePackageList.py b/plugins/Marketplace/RemotePackageList.py
new file mode 100644
index 0000000000..8fa75453c1
--- /dev/null
+++ b/plugins/Marketplace/RemotePackageList.py
@@ -0,0 +1,137 @@
+# Copyright (c) 2021 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
+from PyQt5.QtNetwork import QNetworkReply
+from typing import Optional, TYPE_CHECKING
+
+from cura.CuraApplication import CuraApplication
+from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
+from UM.i18n import i18nCatalog
+from UM.Logger import Logger
+from UM.TaskManagement.HttpRequestManager import HttpRequestManager, HttpRequestData # To request the package list from the API.
+from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope # To request JSON responses from the API.
+
+from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports.
+from .PackageList import PackageList
+from .PackageModel import PackageModel # The contents of this list.
+
+if TYPE_CHECKING:
+ from PyQt5.QtCore import QObject
+
+catalog = i18nCatalog("cura")
+
+
+class RemotePackageList(PackageList):
+ ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once.
+
+ def __init__(self, parent: Optional["QObject"] = None) -> None:
+ super().__init__(parent)
+
+ self._ongoing_request: Optional[HttpRequestData] = None
+ self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
+
+ self._package_type_filter = ""
+ self._request_url = self._initialRequestUrl()
+ self.isLoadingChanged.emit()
+
+ def __del__(self) -> None:
+ """
+ When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++
+ object.
+ """
+ self.abortUpdating()
+
+ @pyqtSlot()
+ def updatePackages(self) -> None:
+ """
+ Make a request for the first paginated page of packages.
+
+ When the request is done, the list will get updated with the new package models.
+ """
+ self.setErrorMessage("") # Clear any previous errors.
+ self.setIsLoading(True)
+
+ self._ongoing_request = HttpRequestManager.getInstance().get(
+ self._request_url,
+ scope = self._scope,
+ callback = self._parseResponse,
+ error_callback = self._onError
+ )
+
+ @pyqtSlot()
+ def abortUpdating(self) -> None:
+ HttpRequestManager.getInstance().abortRequest(self._ongoing_request)
+ self._ongoing_request = None
+
+ def reset(self) -> None:
+ self.clear()
+ self._request_url = self._initialRequestUrl()
+
+ packageTypeFilterChanged = pyqtSignal()
+
+ def setPackageTypeFilter(self, new_filter: str) -> None:
+ if new_filter != self._package_type_filter:
+ self._package_type_filter = new_filter
+ self.reset()
+ self.packageTypeFilterChanged.emit()
+
+ @pyqtProperty(str, fset = setPackageTypeFilter, notify = packageTypeFilterChanged)
+ def packageTypeFilter(self) -> str:
+ """
+ Get the package type this package list is filtering on, like ``plugin`` or ``material``.
+ :return: The package type this list is filtering on.
+ """
+ return self._package_type_filter
+
+ def _initialRequestUrl(self) -> str:
+ """
+ Get the URL to request the first paginated page with.
+ :return: A URL to request.
+ """
+ if self._package_type_filter != "":
+ return f"{Marketplace.PACKAGES_URL}?package_type={self._package_type_filter}&limit={self.ITEMS_PER_PAGE}"
+ return f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}"
+
+ def _parseResponse(self, reply: "QNetworkReply") -> None:
+ """
+ Parse the response from the package list API request.
+
+ This converts that response into PackageModels, and triggers the ListModel to update.
+ :param reply: A reply containing information about a number of packages.
+ """
+ response_data = HttpRequestManager.readJSON(reply)
+ if "data" not in response_data or "links" not in response_data:
+ Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}")
+ self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response."))
+ return
+
+ for package_data in response_data["data"]:
+ try:
+ package = PackageModel(package_data, parent = self)
+ self.appendItem({"package": package}) # Add it to this list model.
+ except RuntimeError:
+ # Setting the ownership of this object to not qml can still result in a RuntimeError. Which can occur when quickly toggling
+ # between de-/constructing RemotePackageLists. This try-except is here to prevent a hard crash when the wrapped C++ object
+ # was deleted when it was still parsing the response
+ return
+
+ self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page.
+ self._ongoing_request = None
+ self.setIsLoading(False)
+ self.setHasMore(self._request_url != "")
+
+ def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None:
+ """
+ Handles networking and server errors when requesting the list of packages.
+ :param reply: The reply with packages. This will most likely be incomplete and should be ignored.
+ :param error: The error status of the request.
+ """
+ if error == QNetworkReply.NetworkError.OperationCanceledError:
+ Logger.debug("Cancelled request for packages.")
+ self._ongoing_request = None
+ return # Don't show an error about this to the user.
+ Logger.error("Could not reach Marketplace server.")
+ self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace."))
+ self._ongoing_request = None
+ self.setIsLoading(False)
diff --git a/plugins/Marketplace/resources/qml/ManagePackagesButton.qml b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml
new file mode 100644
index 0000000000..4a734f45ba
--- /dev/null
+++ b/plugins/Marketplace/resources/qml/ManagePackagesButton.qml
@@ -0,0 +1,45 @@
+// Copyright (c) 2021 Ultimaker B.V.
+// Cura is released under the terms of the LGPLv3 or higher.
+
+import UM 1.2 as UM
+import Cura 1.6 as Cura
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+
+Button
+{
+ id: root
+ width: childrenRect.width
+ height: childrenRect.height
+
+ hoverEnabled: true
+ property color borderColor: hovered ? UM.Theme.getColor("primary") : "transparent"
+ property color backgroundColor: hovered ? UM.Theme.getColor("action_button_hovered") : UM.Theme.getColor("action_button")
+
+ background: Rectangle
+ {
+ color: backgroundColor
+ border.color: borderColor
+ border.width: UM.Theme.getSize("default_lining").width
+ }
+
+ Cura.ToolTip
+ {
+ id: tooltip
+
+ tooltipText: catalog.i18nc("@info:tooltip", "Manage packages")
+ visible: root.hovered
+ }
+
+ UM.RecolorImage
+ {
+ id: icon
+
+ width: UM.Theme.getSize("section_icon").width
+ height: UM.Theme.getSize("section_icon").height
+
+ color: UM.Theme.getColor("icon")
+ source: UM.Theme.getIcon("Settings")
+ }
+}
diff --git a/plugins/Marketplace/resources/qml/ManagedPackages.qml b/plugins/Marketplace/resources/qml/ManagedPackages.qml
new file mode 100644
index 0000000000..243d5bf12e
--- /dev/null
+++ b/plugins/Marketplace/resources/qml/ManagedPackages.qml
@@ -0,0 +1,16 @@
+// Copyright (c) 2021 Ultimaker B.V.
+// Cura is released under the terms of the LGPLv3 or higher.
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import Marketplace 1.0 as Marketplace
+import UM 1.4 as UM
+
+Packages
+{
+ pageTitle: catalog.i18nc("@header", "Manage packages")
+ model: Marketplace.LocalPackageList
+ {
+ }
+}
diff --git a/plugins/Marketplace/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml
index 6cc4a3622d..4f36de01d0 100644
--- a/plugins/Marketplace/resources/qml/Marketplace.qml
+++ b/plugins/Marketplace/resources/qml/Marketplace.qml
@@ -34,7 +34,8 @@ Window
title: "Marketplace" //Seen by Ultimaker as a brand name, so this doesn't get translated.
modality: Qt.NonModal
- Rectangle //Background color.
+ // Background color
+ Rectangle
{
anchors.fill: parent
color: UM.Theme.getColor("main_background")
@@ -45,13 +46,15 @@ Window
spacing: UM.Theme.getSize("default_margin").height
- Item //Page title.
+ // Page title.
+ Item
{
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height + UM.Theme.getSize("default_margin").height
Label
{
+ id: pageTitle
anchors
{
left: parent.left
@@ -63,7 +66,7 @@ Window
font: UM.Theme.getFont("large")
color: UM.Theme.getColor("text")
- text: pageSelectionTabBar.currentItem.pageTitle
+ text: content.item ? content.item.pageTitle: catalog.i18nc("@title", "Loading...")
}
}
@@ -72,10 +75,24 @@ Window
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height
- TabBar //Page selection.
+ ManagePackagesButton
+ {
+ id: managePackagesButton
+
+ anchors.right: parent.right
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+
+ onClicked:
+ {
+ content.source = "ManagedPackages.qml"
+ }
+ }
+
+ // Page selection.
+ TabBar
{
id: pageSelectionTabBar
- anchors.right: parent.right
+ anchors.right: managePackagesButton.left
anchors.rightMargin: UM.Theme.getSize("default_margin").width
spacing: 0
@@ -83,34 +100,43 @@ Window
PackageTypeTab
{
width: implicitWidth
- text: catalog.i18nc("@button", "Plug-ins")
- pageTitle: catalog.i18nc("@header", "Install Plugins")
+ text: catalog.i18nc("@button", "Plugins")
onClicked: content.source = "Plugins.qml"
}
PackageTypeTab
{
width: implicitWidth
text: catalog.i18nc("@button", "Materials")
- pageTitle: catalog.i18nc("@header", "Install Materials")
onClicked: content.source = "Materials.qml"
}
}
}
- Rectangle //Page contents.
+ // Page contents.
+ Rectangle
{
Layout.preferredWidth: parent.width
Layout.fillHeight: true
color: UM.Theme.getColor("detail_background")
- Loader //Page contents.
+ // Page contents.
+ Loader
{
id: content
anchors.fill: parent
anchors.margins: UM.Theme.getSize("default_margin").width
source: "Plugins.qml"
+
+ Connections
+ {
+ target: content
+ function onLoaded()
+ {
+ pageTitle.text = content.item.pageTitle
+ }
+ }
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/plugins/Marketplace/resources/qml/Materials.qml b/plugins/Marketplace/resources/qml/Materials.qml
index 4f3c59d9fb..1d1572976a 100644
--- a/plugins/Marketplace/resources/qml/Materials.qml
+++ b/plugins/Marketplace/resources/qml/Materials.qml
@@ -5,8 +5,9 @@ import Marketplace 1.0 as Marketplace
Packages
{
- model: Marketplace.PackageList
+ pageTitle: catalog.i18nc("@header", "Install Materials")
+ model: Marketplace.RemotePackageList
{
packageTypeFilter: "material"
}
-}
\ No newline at end of file
+}
diff --git a/plugins/Marketplace/resources/qml/Packages.qml b/plugins/Marketplace/resources/qml/Packages.qml
index 5442247668..c9ddf88d16 100644
--- a/plugins/Marketplace/resources/qml/Packages.qml
+++ b/plugins/Marketplace/resources/qml/Packages.qml
@@ -12,9 +12,10 @@ ScrollView
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
property alias model: packagesListview.model
+ property string pageTitle
- Component.onCompleted: model.request()
- Component.onDestruction: model.abortRequest()
+ Component.onCompleted: model.updatePackages()
+ Component.onDestruction: model.abortUpdating()
ListView
{
@@ -23,6 +24,28 @@ ScrollView
spacing: UM.Theme.getSize("default_margin").height
+ section.property: "package.sectionTitle"
+ section.delegate: Rectangle
+ {
+ width: packagesListview.width
+ height: sectionHeaderText.height + UM.Theme.getSize("default_margin").height
+
+ color: UM.Theme.getColor("detail_background")
+
+ required property string section
+
+ Label
+ {
+ id: sectionHeaderText
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+
+ text: parent.section
+ font: UM.Theme.getFont("large")
+ color: UM.Theme.getColor("text")
+ }
+ }
+
delegate: Rectangle
{
width: packagesListview.width
@@ -43,10 +66,12 @@ ScrollView
}
}
- footer: Item //Wrapper item to add spacing between content and footer.
+ //Wrapper item to add spacing between content and footer.
+ footer: Item
{
width: parent.width
- height: UM.Theme.getSize("card").height + packagesListview.spacing
+ height: model.hasFooter || packages.model.errorMessage != "" ? UM.Theme.getSize("card").height + packagesListview.spacing : 0
+ visible: model.hasFooter || packages.model.errorMessage != ""
Button
{
id: loadMoreButton
@@ -55,7 +80,7 @@ ScrollView
anchors.bottom: parent.bottom
enabled: packages.model.hasMore && !packages.model.isLoading || packages.model.errorMessage != ""
- onClicked: packages.model.request() //Load next page in plug-in list.
+ onClicked: packages.model.updatePackages() //Load next page in plug-in list.
background: Rectangle
{
diff --git a/plugins/Marketplace/resources/qml/Plugins.qml b/plugins/Marketplace/resources/qml/Plugins.qml
index 71814f54ad..ef5d92c2e8 100644
--- a/plugins/Marketplace/resources/qml/Plugins.qml
+++ b/plugins/Marketplace/resources/qml/Plugins.qml
@@ -5,8 +5,9 @@ import Marketplace 1.0 as Marketplace
Packages
{
- model: Marketplace.PackageList
+ pageTitle: catalog.i18nc("@header", "Install Plugins")
+ model: Marketplace.RemotePackageList
{
packageTypeFilter: "plugin"
}
-}
\ No newline at end of file
+}
diff --git a/resources/themes/cura-light/icons/default/Settings.svg b/resources/themes/cura-light/icons/default/Settings.svg
new file mode 100644
index 0000000000..feb0ab0cc8
--- /dev/null
+++ b/resources/themes/cura-light/icons/default/Settings.svg
@@ -0,0 +1,3 @@
+
diff --git a/resources/themes/cura-light/icons/high/Settings.svg b/resources/themes/cura-light/icons/high/Settings.svg
new file mode 100644
index 0000000000..1cd2ff324e
--- /dev/null
+++ b/resources/themes/cura-light/icons/high/Settings.svg
@@ -0,0 +1,3 @@
+