Merge pull request #10694 from Ultimaker/CURA-8557_plugins_vs_materials

Add tabs for materials vs. plug-ins to new Marketplace
This commit is contained in:
Jaime van Kessel 2021-10-29 11:46:36 +02:00 committed by GitHub
commit e04021be37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 308 additions and 187 deletions

View File

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt
from PyQt5.QtNetwork import QNetworkReply
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -9,7 +10,7 @@ from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To ma
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To request the package list from the API. 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.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 . import Marketplace # To get the list of packages. Imported this way to prevent circular imports.
@ -17,7 +18,6 @@ from .PackageModel import PackageModel # The contents of this list.
if TYPE_CHECKING: if TYPE_CHECKING:
from PyQt5.QtCore import QObject from PyQt5.QtCore import QObject
from PyQt5.QtNetwork import QNetworkReply
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
@ -33,19 +33,25 @@ class PackageList(ListModel):
PackageRole = Qt.UserRole + 1 PackageRole = Qt.UserRole + 1
ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once. ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once.
INCLUDED_PACKAGE_TYPE = ("material", "plugin") # Only show these kind of packages
def __init__(self, parent: "QObject" = None) -> None: def __init__(self, parent: "QObject" = None) -> None:
super().__init__(parent) super().__init__(parent)
self._is_loading = True self._ongoing_request: Optional[HttpRequestData] = None
self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance())) self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
self._request_url = f"{Marketplace.PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}"
self._error_message = "" self._error_message = ""
self._package_type_filter = ""
self._request_url = self._initialRequestUrl()
self.addRoleName(self.PackageRole, "package") self.addRoleName(self.PackageRole, "package")
self.request() 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()
@pyqtSlot() @pyqtSlot()
def request(self) -> None: def request(self) -> None:
@ -54,31 +60,35 @@ class PackageList(ListModel):
When the request is done, the list will get updated with the new package models. When the request is done, the list will get updated with the new package models.
""" """
self.setIsLoading(True)
self.setErrorMessage("") # Clear any previous errors. self.setErrorMessage("") # Clear any previous errors.
http = HttpRequestManager.getInstance() http = HttpRequestManager.getInstance()
http.get( self._ongoing_request = http.get(
self._request_url, self._request_url,
scope = self._scope, scope = self._scope,
callback = self._parseResponse, callback = self._parseResponse,
error_callback = self._onError error_callback = self._onError
) )
self.isLoadingChanged.emit()
@pyqtSlot()
def abortRequest(self) -> None:
HttpRequestManager.getInstance().abortRequest(self._ongoing_request)
self._ongoing_request = None
def reset(self) -> None:
self.clear()
self._request_url = self._initialRequestUrl()
isLoadingChanged = pyqtSignal() isLoadingChanged = pyqtSignal()
def setIsLoading(self, is_loading: bool) -> None: @pyqtProperty(bool, notify = isLoadingChanged)
if is_loading != self._is_loading:
self._is_loading = is_loading
self.isLoadingChanged.emit()
@pyqtProperty(bool, notify = isLoadingChanged, fset = setIsLoading)
def isLoading(self) -> bool: def isLoading(self) -> bool:
""" """
Gives whether the list is currently loading the first page or loading more pages. 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 downloading, or ``False`` if not downloading.
""" """
return self._is_loading return self._ongoing_request is not None
hasMoreChanged = pyqtSignal() hasMoreChanged = pyqtSignal()
@ -91,6 +101,22 @@ class PackageList(ListModel):
""" """
return self._request_url != "" 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
def setErrorMessage(self, error_message: str) -> None: def setErrorMessage(self, error_message: str) -> None:
if self._error_message != error_message: if self._error_message != error_message:
self._error_message = error_message self._error_message = error_message
@ -108,6 +134,15 @@ class PackageList(ListModel):
""" """
return self._error_message 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: def _parseResponse(self, reply: "QNetworkReply") -> None:
""" """
Parse the response from the package list API request. Parse the response from the package list API request.
@ -122,19 +157,25 @@ class PackageList(ListModel):
return return
for package_data in response_data["data"]: for package_data in response_data["data"]:
if package_data["package_type"] in self.INCLUDED_PACKAGE_TYPE: package = PackageModel(package_data, parent = self)
package = PackageModel(package_data, parent = self) self.appendItem({"package": package}) # Add it to this list model.
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._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page.
self.hasMoreChanged.emit() self.hasMoreChanged.emit()
self.setIsLoading(False) self._ongoing_request = None
self.isLoadingChanged.emit()
def _onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None:
""" """
Handles networking and server errors when requesting the list of packages. 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 reply: The reply with packages. This will most likely be incomplete and should be ignored.
:param error: The error status of the request. :param error: The error status of the request.
""" """
Logger.error(f"Could not reach Marketplace server.") 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.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace."))
self._ongoing_request = None
self.isLoadingChanged.emit()

View File

@ -66,6 +66,35 @@ Window
text: catalog.i18nc("@header", "Install Plugins") text: catalog.i18nc("@header", "Install Plugins")
} }
} }
Item
{
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height
TabBar //Page selection.
{
id: pageSelectionTabBar
anchors.right: parent.right
anchors.rightMargin: UM.Theme.getSize("default_margin").width
spacing: 0
PackageTypeTab
{
width: implicitWidth
text: catalog.i18nc("@button", "Plug-ins")
onClicked: content.source = "Plugins.qml"
}
PackageTypeTab
{
width: implicitWidth
text: catalog.i18nc("@button", "Materials")
onClicked: content.source = "Materials.qml"
}
}
}
Rectangle //Page contents. Rectangle //Page contents.
{ {
Layout.preferredWidth: parent.width Layout.preferredWidth: parent.width

View File

@ -0,0 +1,12 @@
// Copyright (c) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import Marketplace 1.0 as Marketplace
Packages
{
model: Marketplace.PackageList
{
packageTypeFilter: "material"
}
}

View File

@ -0,0 +1,26 @@
// 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 UM 1.0 as UM
TabButton
{
background: Rectangle
{
anchors.fill: parent
color: parent.checked ? UM.Theme.getColor("main_background") : UM.Theme.getColor("detail_background")
border.color: UM.Theme.getColor("detail_background")
border.width: UM.Theme.getSize("thick_lining").width
}
contentItem: Label
{
text: parent.text
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("text")
width: contentWidth
anchors.centerIn: parent
}
}

View File

@ -0,0 +1,175 @@
// 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 UM 1.4 as UM
ScrollView
{
id: packages
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
property alias model: packagesListview.model
Component.onCompleted: model.request()
Component.onDestruction: model.abortRequest()
ListView
{
id: packagesListview
width: parent.width
spacing: UM.Theme.getSize("default_margin").height
delegate: Rectangle
{
width: packagesListview.width
height: UM.Theme.getSize("card").height
color: UM.Theme.getColor("main_background")
radius: UM.Theme.getSize("default_radius").width
Label
{
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Math.round((parent.height - height) / 2)
text: model.package.displayName
font: UM.Theme.getFont("medium_bold")
color: UM.Theme.getColor("text")
}
}
footer: Item //Wrapper item to add spacing between content and footer.
{
width: parent.width
height: UM.Theme.getSize("card").height + packagesListview.spacing
Button
{
id: loadMoreButton
width: parent.width
height: UM.Theme.getSize("card").height
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.
background: Rectangle
{
anchors.fill: parent
radius: UM.Theme.getSize("default_radius").width
color: UM.Theme.getColor("main_background")
}
Row
{
anchors.centerIn: parent
spacing: UM.Theme.getSize("thin_margin").width
states:
[
State
{
name: "Error"
when: packages.model.errorMessage != ""
PropertyChanges
{
target: errorIcon
visible: true
}
PropertyChanges
{
target: loadMoreIcon
visible: false
}
PropertyChanges
{
target: loadMoreLabel
text: catalog.i18nc("@button", "Failed to load packages:") + " " + packages.model.errorMessage + "\n" + catalog.i18nc("@button", "Retry?")
}
},
State
{
name: "Loading"
when: packages.model.isLoading
PropertyChanges
{
target: loadMoreIcon
source: UM.Theme.getIcon("ArrowDoubleCircleRight")
color: UM.Theme.getColor("action_button_disabled_text")
}
PropertyChanges
{
target: loadMoreLabel
text: catalog.i18nc("@button", "Loading")
color: UM.Theme.getColor("action_button_disabled_text")
}
},
State
{
name: "LastPage"
when: !packages.model.hasMore
PropertyChanges
{
target: loadMoreIcon
visible: false
}
PropertyChanges
{
target: loadMoreLabel
text: catalog.i18nc("@button", "No more results to load")
color: UM.Theme.getColor("action_button_disabled_text")
}
}
]
Item
{
width: (errorIcon.visible || loadMoreIcon.visible) ? UM.Theme.getSize("small_button_icon").width : 0
height: UM.Theme.getSize("small_button_icon").height
anchors.verticalCenter: loadMoreLabel.verticalCenter
UM.StatusIcon
{
id: errorIcon
anchors.fill: parent
status: UM.StatusIcon.Status.ERROR
visible: false
}
UM.RecolorImage
{
id: loadMoreIcon
anchors.fill: parent
source: UM.Theme.getIcon("ArrowDown")
color: UM.Theme.getColor("secondary_button_text")
RotationAnimator
{
target: loadMoreIcon
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
running: packages.model.isLoading
alwaysRunToEnd: true
}
}
}
Label
{
id: loadMoreLabel
text: catalog.i18nc("@button", "Load more")
font: UM.Theme.getFont("medium_bold")
color: UM.Theme.getColor("secondary_button_text")
}
}
}
}
}
}

View File

@ -1,174 +1,12 @@
// Copyright (c) 2021 Ultimaker B.V. // Copyright (c) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher. // Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.15
import QtQuick.Controls 2.15
import Marketplace 1.0 as Marketplace import Marketplace 1.0 as Marketplace
import UM 1.4 as UM
ScrollView Packages
{ {
clip: true model: Marketplace.PackageList
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ListView
{ {
id: pluginColumn packageTypeFilter: "plugin"
width: parent.width
model: Marketplace.PackageList
{
id: pluginList
}
spacing: UM.Theme.getSize("default_margin").height
delegate: Rectangle
{
width: pluginColumn.width
height: UM.Theme.getSize("card").height
color: UM.Theme.getColor("main_background")
radius: UM.Theme.getSize("default_radius").width
Label
{
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Math.round((parent.height - height) / 2)
text: model.package.displayName
font: UM.Theme.getFont("medium_bold")
color: UM.Theme.getColor("text")
}
}
footer: Item //Wrapper item to add spacing between content and footer.
{
width: parent.width
height: UM.Theme.getSize("card").height + pluginColumn.spacing
Button
{
id: loadMoreButton
width: parent.width
height: UM.Theme.getSize("card").height
anchors.bottom: parent.bottom
enabled: pluginList.hasMore && !pluginList.isLoading || pluginList.errorMessage != ""
onClicked: pluginList.request() //Load next page in plug-in list.
background: Rectangle
{
anchors.fill: parent
radius: UM.Theme.getSize("default_radius").width
color: UM.Theme.getColor("main_background")
}
Row
{
anchors.centerIn: parent
spacing: UM.Theme.getSize("thin_margin").width
states:
[
State
{
name: "Error"
when: pluginList.errorMessage != ""
PropertyChanges
{
target: errorIcon
visible: true
}
PropertyChanges
{
target: loadMoreIcon
visible: false
}
PropertyChanges
{
target: loadMoreLabel
text: catalog.i18nc("@button", "Failed to load plug-ins:") + " " + pluginList.errorMessage + "\n" + catalog.i18nc("@button", "Retry?")
}
},
State
{
name: "Loading"
when: pluginList.isLoading
PropertyChanges
{
target: loadMoreIcon
source: UM.Theme.getIcon("ArrowDoubleCircleRight")
color: UM.Theme.getColor("action_button_disabled_text")
}
PropertyChanges
{
target: loadMoreLabel
text: catalog.i18nc("@button", "Loading")
color: UM.Theme.getColor("action_button_disabled_text")
}
},
State
{
name: "LastPage"
when: !pluginList.hasMore
PropertyChanges
{
target: loadMoreIcon
visible: false
}
PropertyChanges
{
target: loadMoreLabel
text: catalog.i18nc("@button", "No more results to load")
color: UM.Theme.getColor("action_button_disabled_text")
}
}
]
Item
{
width: (errorIcon.visible || loadMoreIcon.visible) ? UM.Theme.getSize("small_button_icon").width : 0
height: UM.Theme.getSize("small_button_icon").height
anchors.verticalCenter: loadMoreLabel.verticalCenter
UM.StatusIcon
{
id: errorIcon
anchors.fill: parent
status: UM.StatusIcon.Status.ERROR
visible: false
}
UM.RecolorImage
{
id: loadMoreIcon
anchors.fill: parent
source: UM.Theme.getIcon("ArrowDown")
color: UM.Theme.getColor("secondary_button_text")
RotationAnimator
{
target: loadMoreIcon
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
running: pluginList.isLoading
alwaysRunToEnd: true
}
}
}
Label
{
id: loadMoreLabel
text: catalog.i18nc("@button", "Load more")
font: UM.Theme.getFont("medium_bold")
color: UM.Theme.getColor("secondary_button_text")
}
}
}
}
} }
} }