diff --git a/plugins/Marketplace/LocalPackageList.py b/plugins/Marketplace/LocalPackageList.py
new file mode 100644
index 0000000000..ff221ed606
--- /dev/null
+++ b/plugins/Marketplace/LocalPackageList.py
@@ -0,0 +1,48 @@
+# Copyright (c) 2021 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from PyQt5.QtCore import pyqtSlot, Qt
+from typing import TYPE_CHECKING
+
+from UM.i18n import i18nCatalog
+
+from cura.CuraApplication import CuraApplication
+
+from .PackageList import PackageList
+from .PackageModel import PackageModel # The contents of this list.
+
+if TYPE_CHECKING:
+ from PyQt5.QtCore import QObject
+
+catalog = i18nCatalog("cura")
+
+
+class LocalPackageList(PackageList):
+ PackageRole = Qt.UserRole + 1
+
+ def __init__(self, parent: "QObject" = None) -> None:
+ super().__init__(parent)
+ self._application = CuraApplication.getInstance()
+
+ @pyqtSlot()
+ def updatePackages(self) -> None:
+ """
+ Make a request for the first paginated page of packages.
+
+ When the request is done, the list will get updated with the new package models.
+ """
+ self.setErrorMessage("") # Clear any previous errors.
+ self.setIsLoading(True)
+ self._getLocalPackages()
+
+ def _getLocalPackages(self) -> None:
+ plugin_registry = self._application.getPluginRegistry()
+ package_manager = self._application.getPackageManager()
+
+ bundled = plugin_registry.getInstalledPlugins()
+ for b in bundled:
+ package = PackageModel({"package_id": b, "display_name": b}, parent = self)
+ self.appendItem({"package": package})
+ packages = package_manager.getInstalledPackageIDs()
+ self.setIsLoading(False)
+ self.setHasMore(False)
diff --git a/plugins/Marketplace/Marketplace.py b/plugins/Marketplace/Marketplace.py
index b3f573f792..9418174d19 100644
--- a/plugins/Marketplace/Marketplace.py
+++ b/plugins/Marketplace/Marketplace.py
@@ -13,7 +13,8 @@ from UM.Extension import Extension # We are implementing the main object of an
from UM.Logger import Logger
from UM.PluginRegistry import PluginRegistry # To find out where we are stored (the proper way).
-from .PackageList import PackageList # To register this type with QML.
+from .RemotePackageList import RemotePackageList # To register this type with QML.
+from .LocalPackageList import LocalPackageList # To register this type with QML.
if TYPE_CHECKING:
from PyQt5.QtCore import QObject
@@ -31,7 +32,8 @@ class Marketplace(Extension):
super().__init__()
self._window: Optional["QObject"] = None # If the window has been loaded yet, it'll be cached in here.
- qmlRegisterType(PackageList, "Marketplace", 1, 0, "PackageList")
+ qmlRegisterType(RemotePackageList, "Marketplace", 1, 0, "RemotePackageList")
+ qmlRegisterType(LocalPackageList, "Marketplace", 1, 0, "LocalPackageList")
@pyqtSlot()
def show(self) -> None:
diff --git a/plugins/Marketplace/PackageList.py b/plugins/Marketplace/PackageList.py
index 424b66fe21..00532313f0 100644
--- a/plugins/Marketplace/PackageList.py
+++ b/plugins/Marketplace/PackageList.py
@@ -2,19 +2,10 @@
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt
-from PyQt5.QtNetwork import QNetworkReply
-from typing import Optional, TYPE_CHECKING
+from typing import TYPE_CHECKING
-from cura.CuraApplication import CuraApplication
-from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
from UM.i18n import i18nCatalog
-from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
-from UM.TaskManagement.HttpRequestManager import HttpRequestManager, HttpRequestData # To request the package list from the API.
-from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope # To request JSON responses from the API.
-
-from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports.
-from .PackageModel import PackageModel # The contents of this list.
if TYPE_CHECKING:
from PyQt5.QtCore import QObject
@@ -23,99 +14,60 @@ catalog = i18nCatalog("cura")
class PackageList(ListModel):
- """
- Represents a list of packages to be displayed in the interface.
-
- The list can be filtered (e.g. on package type, materials vs. plug-ins) and
- paginated.
- """
-
PackageRole = Qt.UserRole + 1
- ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once.
-
def __init__(self, parent: "QObject" = None) -> None:
super().__init__(parent)
-
- self._ongoing_request: Optional[HttpRequestData] = None
- self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
self._error_message = ""
-
- self._package_type_filter = ""
- self._request_url = self._initialRequestUrl()
-
self.addRoleName(self.PackageRole, "package")
-
- def __del__(self) -> None:
- """
- When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++
- object.
- """
- self.abortRequest()
+ self._is_loading = False
+ self._has_more = False
@pyqtSlot()
- def request(self) -> None:
+ def updatePackages(self) -> None:
"""
- Make a request for the first paginated page of packages.
-
- When the request is done, the list will get updated with the new package models.
+ Initialize the first page of packages
"""
self.setErrorMessage("") # Clear any previous errors.
-
- http = HttpRequestManager.getInstance()
- self._ongoing_request = http.get(
- self._request_url,
- scope = self._scope,
- callback = self._parseResponse,
- error_callback = self._onError
- )
self.isLoadingChanged.emit()
@pyqtSlot()
- def abortRequest(self) -> None:
- HttpRequestManager.getInstance().abortRequest(self._ongoing_request)
- self._ongoing_request = None
+ def abortUpdating(self) -> None:
+ pass
def reset(self) -> None:
self.clear()
- self._request_url = self._initialRequestUrl()
isLoadingChanged = pyqtSignal()
- @pyqtProperty(bool, notify = isLoadingChanged)
+ def setIsLoading(self, value: bool) -> None:
+ if self._is_loading != value:
+ self._is_loading = value
+ self.isLoadingChanged.emit()
+
+ @pyqtProperty(bool, fset = setIsLoading, notify = isLoadingChanged)
def isLoading(self) -> bool:
"""
Gives whether the list is currently loading the first page or loading more pages.
- :return: ``True`` if the list is downloading, or ``False`` if not downloading.
+ :return: ``True`` if the list is being gathered, or ``False`` if .
"""
- return self._ongoing_request is not None
+ return self._is_loading
hasMoreChanged = pyqtSignal()
- @pyqtProperty(bool, notify = hasMoreChanged)
+ def setHasMore(self, value: bool) -> None:
+ if self._has_more != value:
+ self._has_more = value
+ self.hasMoreChanged.emit()
+
+ @pyqtProperty(bool, fset = setHasMore, notify = hasMoreChanged)
def hasMore(self) -> bool:
"""
Returns whether there are more packages to load.
:return: ``True`` if there are more packages to load, or ``False`` if we've reached the last page of the
pagination.
"""
- return self._request_url != ""
-
- packageTypeFilterChanged = pyqtSignal()
-
- def setPackageTypeFilter(self, new_filter: str) -> None:
- if new_filter != self._package_type_filter:
- self._package_type_filter = new_filter
- self.reset()
- self.packageTypeFilterChanged.emit()
-
- @pyqtProperty(str, notify = packageTypeFilterChanged, fset = setPackageTypeFilter)
- def packageTypeFilter(self) -> str:
- """
- Get the package type this package list is filtering on, like ``plugin`` or ``material``.
- :return: The package type this list is filtering on.
- """
- return self._package_type_filter
+ return self._has_more
def setErrorMessage(self, error_message: str) -> None:
if self._error_message != error_message:
@@ -133,49 +85,3 @@ class PackageList(ListModel):
:return: An error message, if any, or an empty string if everything went okay.
"""
return self._error_message
-
- def _initialRequestUrl(self) -> str:
- """
- Get the URL to request the first paginated page with.
- :return: A URL to request.
- """
- if self._package_type_filter != "":
- return f"{Marketplace.PACKAGES_URL}?package_type={self._package_type_filter}&limit={self.ITEMS_PER_PAGE}"
- return f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}"
-
- def _parseResponse(self, reply: "QNetworkReply") -> None:
- """
- Parse the response from the package list API request.
-
- This converts that response into PackageModels, and triggers the ListModel to update.
- :param reply: A reply containing information about a number of packages.
- """
- response_data = HttpRequestManager.readJSON(reply)
- if "data" not in response_data or "links" not in response_data:
- Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}")
- self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response."))
- return
-
- for package_data in response_data["data"]:
- package = PackageModel(package_data, parent = self)
- self.appendItem({"package": package}) # Add it to this list model.
-
- self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page.
- self.hasMoreChanged.emit()
- self._ongoing_request = None
- self.isLoadingChanged.emit()
-
- def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None:
- """
- Handles networking and server errors when requesting the list of packages.
- :param reply: The reply with packages. This will most likely be incomplete and should be ignored.
- :param error: The error status of the request.
- """
- if error == QNetworkReply.NetworkError.OperationCanceledError:
- Logger.debug("Cancelled request for packages.")
- self._ongoing_request = None
- return # Don't show an error about this to the user.
- Logger.error("Could not reach Marketplace server.")
- self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace."))
- self._ongoing_request = None
- self.isLoadingChanged.emit()
diff --git a/plugins/Marketplace/RemotePackageList.py b/plugins/Marketplace/RemotePackageList.py
new file mode 100644
index 0000000000..3cfb11d6ba
--- /dev/null
+++ b/plugins/Marketplace/RemotePackageList.py
@@ -0,0 +1,131 @@
+# Copyright (c) 2021 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
+from PyQt5.QtNetwork import QNetworkReply
+from typing import Optional, TYPE_CHECKING
+
+from cura.CuraApplication import CuraApplication
+from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
+from UM.i18n import i18nCatalog
+from UM.Logger import Logger
+from UM.TaskManagement.HttpRequestManager import HttpRequestManager, HttpRequestData # To request the package list from the API.
+from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope # To request JSON responses from the API.
+
+from . import Marketplace # To get the list of packages. Imported this way to prevent circular imports.
+from .PackageList import PackageList
+from .PackageModel import PackageModel # The contents of this list.
+
+if TYPE_CHECKING:
+ from PyQt5.QtCore import QObject
+ from PyQt5.QtNetwork import QNetworkReply
+
+catalog = i18nCatalog("cura")
+
+
+class RemotePackageList(PackageList):
+ ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once.
+
+ def __init__(self, parent: "QObject" = None) -> None:
+ super().__init__(parent)
+
+ self._ongoing_request: Optional[HttpRequestData] = None
+ self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
+
+ self._package_type_filter = ""
+ self._request_url = self._initialRequestUrl()
+
+ def __del__(self) -> None:
+ """
+ When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++
+ object.
+ """
+ self.abortUpdating()
+
+ @pyqtSlot()
+ def updatePackages(self) -> None:
+ """
+ Make a request for the first paginated page of packages.
+
+ When the request is done, the list will get updated with the new package models.
+ """
+ self.setErrorMessage("") # Clear any previous errors.
+ self.setIsLoading(True)
+
+ self._ongoing_request = HttpRequestManager.getInstance().get(
+ self._request_url,
+ scope = self._scope,
+ callback = self._parseResponse,
+ error_callback = self._onError
+ )
+
+ @pyqtSlot()
+ def abortUpdating(self) -> None:
+ HttpRequestManager.getInstance().abortRequest(self._ongoing_request)
+ self._ongoing_request = None
+
+ def reset(self) -> None:
+ self.clear()
+ self._request_url = self._initialRequestUrl()
+
+ packageTypeFilterChanged = pyqtSignal()
+
+ def setPackageTypeFilter(self, new_filter: str) -> None:
+ if new_filter != self._package_type_filter:
+ self._package_type_filter = new_filter
+ self.reset()
+ self.packageTypeFilterChanged.emit()
+
+ @pyqtProperty(str, fset = setPackageTypeFilter, notify = packageTypeFilterChanged)
+ def packageTypeFilter(self) -> str:
+ """
+ Get the package type this package list is filtering on, like ``plugin`` or ``material``.
+ :return: The package type this list is filtering on.
+ """
+ return self._package_type_filter
+
+ def _initialRequestUrl(self) -> str:
+ """
+ Get the URL to request the first paginated page with.
+ :return: A URL to request.
+ """
+ if self._package_type_filter != "":
+ return f"{Marketplace.PACKAGES_URL}?package_type={self._package_type_filter}&limit={self.ITEMS_PER_PAGE}"
+ return f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}"
+
+ def _parseResponse(self, reply: "QNetworkReply") -> None:
+ """
+ Parse the response from the package list API request.
+
+ This converts that response into PackageModels, and triggers the ListModel to update.
+ :param reply: A reply containing information about a number of packages.
+ """
+ response_data = HttpRequestManager.readJSON(reply)
+ if "data" not in response_data or "links" not in response_data:
+ Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}")
+ self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response."))
+ return
+
+ for package_data in response_data["data"]:
+ package = PackageModel(package_data, parent = self)
+ self.appendItem({"package": package}) # Add it to this list model.
+
+ self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page.
+ self._ongoing_request = None
+ self.setIsLoading(False)
+ self.setHasMore(self._request_url != "")
+
+ def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None:
+ """
+ Handles networking and server errors when requesting the list of packages.
+ :param reply: The reply with packages. This will most likely be incomplete and should be ignored.
+ :param error: The error status of the request.
+ """
+ if error == QNetworkReply.NetworkError.OperationCanceledError:
+ Logger.debug("Cancelled request for packages.")
+ self._ongoing_request = None
+ return # Don't show an error about this to the user.
+ Logger.error("Could not reach Marketplace server.")
+ self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace."))
+ self._ongoing_request = None
+ self.setIsLoading(False)
diff --git a/plugins/Marketplace/resources/qml/ManagedPackages.qml b/plugins/Marketplace/resources/qml/ManagedPackages.qml
new file mode 100644
index 0000000000..243d5bf12e
--- /dev/null
+++ b/plugins/Marketplace/resources/qml/ManagedPackages.qml
@@ -0,0 +1,16 @@
+// Copyright (c) 2021 Ultimaker B.V.
+// Cura is released under the terms of the LGPLv3 or higher.
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import Marketplace 1.0 as Marketplace
+import UM 1.4 as UM
+
+Packages
+{
+ pageTitle: catalog.i18nc("@header", "Manage packages")
+ model: Marketplace.LocalPackageList
+ {
+ }
+}
diff --git a/plugins/Marketplace/resources/qml/Marketplace.qml b/plugins/Marketplace/resources/qml/Marketplace.qml
index 6cc4a3622d..7004b69d46 100644
--- a/plugins/Marketplace/resources/qml/Marketplace.qml
+++ b/plugins/Marketplace/resources/qml/Marketplace.qml
@@ -34,7 +34,8 @@ Window
title: "Marketplace" //Seen by Ultimaker as a brand name, so this doesn't get translated.
modality: Qt.NonModal
- Rectangle //Background color.
+ // Background color
+ Rectangle
{
anchors.fill: parent
color: UM.Theme.getColor("main_background")
@@ -45,13 +46,15 @@ Window
spacing: UM.Theme.getSize("default_margin").height
- Item //Page title.
+ // Page title.
+ Item
{
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height + UM.Theme.getSize("default_margin").height
Label
{
+ id: pageTitle
anchors
{
left: parent.left
@@ -63,7 +66,7 @@ Window
font: UM.Theme.getFont("large")
color: UM.Theme.getColor("text")
- text: pageSelectionTabBar.currentItem.pageTitle
+ text: ""
}
}
@@ -72,10 +75,56 @@ Window
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height
- TabBar //Page selection.
+ Button
+ {
+ id: managePackagesButton
+
+ hoverEnabled: true
+
+ width: childrenRect.width
+ height: childrenRect.height
+
+ anchors.right: parent.right
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+
+ background: Rectangle
+ {
+ color: UM.Theme.getColor("action_button")
+ border.color: "transparent"
+ border.width: UM.Theme.getSize("default_lining").width
+ }
+
+ Cura.ToolTip
+ {
+ id: managePackagesTooltip
+
+ tooltipText: catalog.i18nc("@info:tooltip", "Manage packages")
+ arrowSize: 0
+ visible: managePackagesButton.hovered
+ }
+
+ UM.RecolorImage
+ {
+ id: managePackagesIcon
+
+ width: UM.Theme.getSize("section_icon").width
+ height: UM.Theme.getSize("section_icon").height
+
+ color: UM.Theme.getColor("icon")
+ source: UM.Theme.getIcon("Settings")
+ }
+
+ onClicked:
+ {
+ content.source = "ManagedPackages.qml"
+ }
+ }
+
+ // Page selection.
+ TabBar
{
id: pageSelectionTabBar
- anchors.right: parent.right
+ anchors.right: managePackagesButton.left
anchors.rightMargin: UM.Theme.getSize("default_margin").width
spacing: 0
@@ -84,33 +133,42 @@ Window
{
width: implicitWidth
text: catalog.i18nc("@button", "Plug-ins")
- pageTitle: catalog.i18nc("@header", "Install Plugins")
onClicked: content.source = "Plugins.qml"
}
PackageTypeTab
{
width: implicitWidth
text: catalog.i18nc("@button", "Materials")
- pageTitle: catalog.i18nc("@header", "Install Materials")
onClicked: content.source = "Materials.qml"
}
}
}
- Rectangle //Page contents.
+ // Page contents.
+ Rectangle
{
Layout.preferredWidth: parent.width
Layout.fillHeight: true
color: UM.Theme.getColor("detail_background")
- Loader //Page contents.
+ // Page contents.
+ Loader
{
id: content
anchors.fill: parent
anchors.margins: UM.Theme.getSize("default_margin").width
source: "Plugins.qml"
+
+ Connections
+ {
+ target: content
+ onLoaded: function()
+ {
+ pageTitle.text = content.item.pageTitle
+ }
+ }
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/plugins/Marketplace/resources/qml/Materials.qml b/plugins/Marketplace/resources/qml/Materials.qml
index 4f3c59d9fb..1d1572976a 100644
--- a/plugins/Marketplace/resources/qml/Materials.qml
+++ b/plugins/Marketplace/resources/qml/Materials.qml
@@ -5,8 +5,9 @@ import Marketplace 1.0 as Marketplace
Packages
{
- model: Marketplace.PackageList
+ pageTitle: catalog.i18nc("@header", "Install Materials")
+ model: Marketplace.RemotePackageList
{
packageTypeFilter: "material"
}
-}
\ No newline at end of file
+}
diff --git a/plugins/Marketplace/resources/qml/Packages.qml b/plugins/Marketplace/resources/qml/Packages.qml
index 5442247668..cdc066626c 100644
--- a/plugins/Marketplace/resources/qml/Packages.qml
+++ b/plugins/Marketplace/resources/qml/Packages.qml
@@ -12,9 +12,10 @@ ScrollView
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
property alias model: packagesListview.model
+ property string pageTitle
- Component.onCompleted: model.request()
- Component.onDestruction: model.abortRequest()
+ Component.onCompleted: model.updatePackages()
+ Component.onDestruction: model.abortUpdating()
ListView
{
@@ -43,7 +44,8 @@ ScrollView
}
}
- footer: Item //Wrapper item to add spacing between content and footer.
+ //Wrapper item to add spacing between content and footer.
+ footer: Item
{
width: parent.width
height: UM.Theme.getSize("card").height + packagesListview.spacing
@@ -55,7 +57,7 @@ ScrollView
anchors.bottom: parent.bottom
enabled: packages.model.hasMore && !packages.model.isLoading || packages.model.errorMessage != ""
- onClicked: packages.model.request() //Load next page in plug-in list.
+ onClicked: packages.model.updatePackages() //Load next page in plug-in list.
background: Rectangle
{
diff --git a/plugins/Marketplace/resources/qml/Plugins.qml b/plugins/Marketplace/resources/qml/Plugins.qml
index 71814f54ad..ef5d92c2e8 100644
--- a/plugins/Marketplace/resources/qml/Plugins.qml
+++ b/plugins/Marketplace/resources/qml/Plugins.qml
@@ -5,8 +5,9 @@ import Marketplace 1.0 as Marketplace
Packages
{
- model: Marketplace.PackageList
+ pageTitle: catalog.i18nc("@header", "Install Plugins")
+ model: Marketplace.RemotePackageList
{
packageTypeFilter: "plugin"
}
-}
\ No newline at end of file
+}
diff --git a/resources/themes/cura-light/icons/default/Settings.svg b/resources/themes/cura-light/icons/default/Settings.svg
new file mode 100644
index 0000000000..feb0ab0cc8
--- /dev/null
+++ b/resources/themes/cura-light/icons/default/Settings.svg
@@ -0,0 +1,3 @@
+
diff --git a/resources/themes/cura-light/icons/high/Settings.svg b/resources/themes/cura-light/icons/high/Settings.svg
new file mode 100644
index 0000000000..1cd2ff324e
--- /dev/null
+++ b/resources/themes/cura-light/icons/high/Settings.svg
@@ -0,0 +1,3 @@
+