diff --git a/src/plugins/qmldesigner/CMakeLists.txt b/src/plugins/qmldesigner/CMakeLists.txt index de70193b0d6..1552f0f77d9 100644 --- a/src/plugins/qmldesigner/CMakeLists.txt +++ b/src/plugins/qmldesigner/CMakeLists.txt @@ -676,6 +676,7 @@ extend_qtc_plugin(QmlDesigner edit3dwidget.cpp edit3dwidget.h edit3dcanvas.cpp edit3dcanvas.h edit3dactions.cpp edit3dactions.h + edit3dmaterialsaction.cpp edit3dmaterialsaction.h edit3dtoolbarmenu.cpp edit3dtoolbarmenu.h backgroundcolorselection.cpp backgroundcolorselection.h bakelights.cpp bakelights.h diff --git a/src/plugins/qmldesigner/components/contentlibrary/contentlibraryview.cpp b/src/plugins/qmldesigner/components/contentlibrary/contentlibraryview.cpp index 8fed0fc442e..684cf9d38c8 100644 --- a/src/plugins/qmldesigner/components/contentlibrary/contentlibraryview.cpp +++ b/src/plugins/qmldesigner/components/contentlibrary/contentlibraryview.cpp @@ -437,36 +437,14 @@ void ContentLibraryView::applyBundleMaterialToDropTarget(const ModelNode &bundle executeInTransaction("ContentLibraryView::applyBundleMaterialToDropTarget", [&] { ModelNode newMatNode = typeName.size() ? createMaterial(typeName) : bundleMat; - - // TODO: unify this logic as it exist elsewhere also - auto expToList = [](const QString &exp) { - QString copy = exp; - copy = copy.remove("[").remove("]"); - - QStringList tmp = copy.split(',', Qt::SkipEmptyParts); - for (QString &str : tmp) - str = str.trimmed(); - - return tmp; - }; - - auto listToExp = [](QStringList &stringList) { - if (stringList.size() > 1) - return QString("[" + stringList.join(",") + "]"); - - if (stringList.size() == 1) - return stringList.first(); - - return QString(); - }; - for (const ModelNode &target : std::as_const(m_bundleMaterialTargets)) { if (target.isValid() && target.metaInfo().isQtQuick3DModel()) { QmlObjectNode qmlObjNode(target); if (m_bundleMaterialAddToSelected) { - QStringList matList = expToList(qmlObjNode.expression("materials")); + QStringList matList = ModelUtils::expressionToList( + qmlObjNode.expression("materials")); matList.append(newMatNode.id()); - QString updatedExp = listToExp(matList); + QString updatedExp = ModelUtils::listToExpression(matList); qmlObjNode.setBindingProperty("materials", updatedExp); } else { qmlObjNode.setBindingProperty("materials", newMatNode.id()); @@ -488,35 +466,14 @@ void ContentLibraryView::applyBundleMaterialToDropTarget(const ModelNode &bundle executeInTransaction("ContentLibraryView::applyBundleMaterialToDropTarget", [&] { ModelNode newMatNode = metaInfo.isValid() ? createMaterial(metaInfo) : bundleMat; - // TODO: unify this logic as it exist elsewhere also - auto expToList = [](const QString &exp) { - QString copy = exp; - copy = copy.remove("[").remove("]"); - - QStringList tmp = copy.split(',', Qt::SkipEmptyParts); - for (QString &str : tmp) - str = str.trimmed(); - - return tmp; - }; - - auto listToExp = [](QStringList &stringList) { - if (stringList.size() > 1) - return QString("[" + stringList.join(",") + "]"); - - if (stringList.size() == 1) - return stringList.first(); - - return QString(); - }; - for (const ModelNode &target : std::as_const(m_bundleMaterialTargets)) { if (target.isValid() && target.metaInfo().isQtQuick3DModel()) { QmlObjectNode qmlObjNode(target); if (m_bundleMaterialAddToSelected) { - QStringList matList = expToList(qmlObjNode.expression("materials")); + QStringList matList = ModelUtils::expressionToList( + qmlObjNode.expression("materials")); matList.append(newMatNode.id()); - QString updatedExp = listToExp(matList); + QString updatedExp = ModelUtils::listToExpression(matList); qmlObjNode.setBindingProperty("materials", updatedExp); } else { qmlObjNode.setBindingProperty("materials", newMatNode.id()); diff --git a/src/plugins/qmldesigner/components/edit3d/edit3dmaterialsaction.cpp b/src/plugins/qmldesigner/components/edit3d/edit3dmaterialsaction.cpp new file mode 100644 index 00000000000..5ba171cc2e7 --- /dev/null +++ b/src/plugins/qmldesigner/components/edit3d/edit3dmaterialsaction.cpp @@ -0,0 +1,196 @@ +// Copyright (C) 2024 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +#include "edit3dmaterialsaction.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +using namespace Qt::StringLiterals; + +namespace QmlDesigner { + +static QString getMaterialName(const ModelNode &material, bool forceIncludeId = false) +{ + QString materialName = material.variantProperty("objectName").value().toString(); + if (materialName.isEmpty() || forceIncludeId) + materialName.append(QString("[%1]").arg(material.id())); + return materialName; +} + +struct MaterialNameLessThan +{ + bool operator()(const ModelNode &a, const ModelNode &b) + { + const QString aName = getMaterialName(a, true); + const QString bName = getMaterialName(b, true); + return aName.compare(bName, Qt::CaseSensitive) < 0; + }; +}; + +static QList getMaterials(const ModelNode &node) +{ + BindingProperty matsProp = node.bindingProperty("materials"); + if (!matsProp.exists()) + return {}; + + Model *model = node.model(); + QList materials; + if (model->hasId(matsProp.expression())) + materials.append(model->modelNodeForId(matsProp.expression())); + else + materials = matsProp.resolveToModelNodeList(); + + return materials; +} + +static QList getSortedMaterials(const ModelNode &node) +{ + QList materials = getMaterials(node); + std::sort(materials.begin(), materials.end(), MaterialNameLessThan{}); + return materials; +} + +static void removeMaterialFromNode(const ModelNode &node, const QString &materialId, int nthMaterial) +{ + BindingProperty matsProp = node.bindingProperty("materials"); + if (!matsProp.exists()) + return; + + const QString materialsExpression = matsProp.expression(); + Model *model = node.model(); + + if (matsProp.isList()) { + matsProp.removeModelNodeFromArray(model->modelNodeForId(materialId)); + QStringList nodeMaterials = ModelUtils::expressionToList(materialsExpression); + + int indexToBeRemoved = -1; + do + indexToBeRemoved = nodeMaterials.indexOf(materialId, indexToBeRemoved + 1); + while (nthMaterial-- && indexToBeRemoved != -1); + + if (indexToBeRemoved != -1) + nodeMaterials.removeAt(indexToBeRemoved); + + if (nodeMaterials.isEmpty()) + matsProp.parentModelNode().removeProperty(matsProp.name()); + else if (nodeMaterials.size() == 1) + matsProp.setExpression(nodeMaterials.first()); + else + matsProp.setExpression('[' + nodeMaterials.join(',') + ']'); + } else if (materialsExpression == materialId) { + matsProp.parentModelNode().removeProperty(matsProp.name()); + } +} + +static QList commonMaterialsOfNodes(const QList &selectedNodes) +{ + if (selectedNodes.isEmpty()) + return {}; + + if (selectedNodes.size() == 1) + return getMaterials(selectedNodes.first()); + + QList commonMaterials = getSortedMaterials(selectedNodes.first()); + for (const ModelNode &node : Utils::span(selectedNodes).subspan(1)) { + const QList materials = getSortedMaterials(node); + QList materialIntersection; + std::set_intersection(commonMaterials.begin(), + commonMaterials.end(), + materials.begin(), + materials.end(), + std::back_inserter(materialIntersection), + MaterialNameLessThan{}); + std::swap(commonMaterials, materialIntersection); + if (commonMaterials.isEmpty()) + return {}; + } + return commonMaterials; +} + +Edit3DMaterialsAction::Edit3DMaterialsAction(const QIcon &icon, QObject *parent) + : QAction(icon, tr("Materials"), parent) +{ + this->setMenu(new QMenu("Materials")); + connect(this, &QObject::destroyed, this->menu(), &QObject::deleteLater); +} + +void Edit3DMaterialsAction::updateMenu(const QList &selecedNodes) +{ + QMenu *menu = this->menu(); + QTC_ASSERT(menu, return); + + m_selectedNodes = selecedNodes; + + menu->clear(); + const QList materials = commonMaterialsOfNodes(m_selectedNodes); + QHash nthMaterialMap; // + + for (const ModelNode &material : materials) { + int nthMaterialWithTheSameId = nthMaterialMap.value(material, -1) + 1; + nthMaterialMap.insert(material, nthMaterialWithTheSameId); + QAction *materialAction = createMaterialAction(material, menu, nthMaterialWithTheSameId); + if (materialAction) + menu->addAction(materialAction); + } + + setVisible(!menu->actions().isEmpty()); + setEnabled(isVisible()); +} + +void Edit3DMaterialsAction::removeMaterial(const QString &materialId, int nthMaterial) +{ + if (m_selectedNodes.isEmpty()) + return; + + AbstractView *nodesView = m_selectedNodes.first().view(); + nodesView->executeInTransaction(__FUNCTION__, [&] { + for (ModelNode &node : m_selectedNodes) + removeMaterialFromNode(node, materialId, nthMaterial); + }); +} + +QAction *Edit3DMaterialsAction::createMaterialAction(const ModelNode &material, + QMenu *parentMenu, + int nthMaterial) +{ + const QString materialId = material.id(); + if (materialId.isEmpty()) + return nullptr; + + QString materialName = getMaterialName(material); + + QAction *action = new QAction(materialName, parentMenu); + QMenu *menu = new QMenu(materialName, parentMenu); + connect(action, &QObject::destroyed, menu, &QObject::deleteLater); + + QAction *removeMaterialAction = new QAction(tr("Remove"), menu); + connect(removeMaterialAction, + &QAction::triggered, + menu, + std::bind(&Edit3DMaterialsAction::removeMaterial, this, materialId, nthMaterial)); + + QAction *editMaterialAction = new QAction(tr("Edit"), menu); + connect(editMaterialAction, &QAction::triggered, menu, [material] { + QmlDesignerPlugin::instance()->mainWidget()->showDockWidget("MaterialEditor", true); + if (auto materialView = material.view()) + materialView->emitCustomNotification("select_material", {material}); + }); + + menu->addAction(removeMaterialAction); + menu->addAction(editMaterialAction); + action->setMenu(menu); + + return action; +} + +}; // namespace QmlDesigner diff --git a/src/plugins/qmldesigner/components/edit3d/edit3dmaterialsaction.h b/src/plugins/qmldesigner/components/edit3d/edit3dmaterialsaction.h new file mode 100644 index 00000000000..3c483ffeb39 --- /dev/null +++ b/src/plugins/qmldesigner/components/edit3d/edit3dmaterialsaction.h @@ -0,0 +1,31 @@ +// Copyright (C) 2024 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +#pragma once + +#include + +#include +#include + +namespace QmlDesigner { + +class ModelNode; + +class Edit3DMaterialsAction : public QAction +{ + Q_OBJECT +public: + Edit3DMaterialsAction(const QIcon &icon, QObject *parent); + + void updateMenu(const QList &selecedNodes); + +private slots: + void removeMaterial(const QString &materialId, int nthMaterial); + +private: + QAction *createMaterialAction(const ModelNode &material, QMenu *parentMenu, int nthMaterial); + + QList m_selectedNodes; +}; + +} // namespace QmlDesigner diff --git a/src/plugins/qmldesigner/components/edit3d/edit3dwidget.cpp b/src/plugins/qmldesigner/components/edit3d/edit3dwidget.cpp index ff142048032..82602fe79b6 100644 --- a/src/plugins/qmldesigner/components/edit3d/edit3dwidget.cpp +++ b/src/plugins/qmldesigner/components/edit3d/edit3dwidget.cpp @@ -5,6 +5,7 @@ #include "edit3dactions.h" #include "edit3dcanvas.h" +#include "edit3dmaterialsaction.h" #include "edit3dtoolbarmenu.h" #include "edit3dview.h" @@ -198,13 +199,8 @@ void Edit3DWidget::createContextMenu() DocumentManager::goIntoComponent(m_view->singleSelectedModelNode()); }); - m_editMaterialAction = m_contextMenu->addAction( - contextIcon(DesignerIcons::MaterialIcon), - tr("Edit Material"), [&] { - SelectionContext selCtx(m_view); - selCtx.setTargetNode(m_contextMenuTarget); - ModelNodeOperations::editMaterial(selCtx); - }); + m_materialsAction = new Edit3DMaterialsAction(contextIcon(DesignerIcons::MaterialIcon), this); + m_contextMenu->addAction(m_materialsAction); m_contextMenu->addSeparator(); @@ -649,7 +645,7 @@ void Edit3DWidget::showContextMenu(const QPoint &pos, const ModelNode &modelNode m_createSubMenu->setEnabled(!isSceneLocked()); m_editComponentAction->setEnabled(isSingleComponent); - m_editMaterialAction->setEnabled(isModel); + m_materialsAction->setEnabled(isModel); m_duplicateAction->setEnabled(selectionExcludingRoot); m_copyAction->setEnabled(selectionExcludingRoot); m_pasteAction->setEnabled(isPasteAvailable()); @@ -663,6 +659,7 @@ void Edit3DWidget::showContextMenu(const QPoint &pos, const ModelNode &modelNode m_bakeLightsAction->setEnabled(view()->bakeLightsAction()->action()->isEnabled()); m_addToContentLibAction->setEnabled(isNode && !isInBundle); m_exportBundleAction->setEnabled(isNode); + m_materialsAction->updateMenu(view()->selectedModelNodes()); if (m_view) { int idx = m_view->activeSplit(); diff --git a/src/plugins/qmldesigner/components/edit3d/edit3dwidget.h b/src/plugins/qmldesigner/components/edit3d/edit3dwidget.h index 4f0dfa19252..cfed01669e3 100644 --- a/src/plugins/qmldesigner/components/edit3d/edit3dwidget.h +++ b/src/plugins/qmldesigner/components/edit3d/edit3dwidget.h @@ -21,6 +21,7 @@ namespace QmlDesigner { class Edit3DView; class Edit3DCanvas; class ToolBox; +class Edit3DMaterialsAction; struct ItemLibraryDetails { QString name; @@ -89,7 +90,6 @@ private: QPointer m_contextMenu; QPointer m_bakeLightsAction; QPointer m_editComponentAction; - QPointer m_editMaterialAction; QPointer m_duplicateAction; QPointer m_copyAction; QPointer m_pasteAction; @@ -103,6 +103,7 @@ private: QPointer m_importBundleAction; QPointer m_exportBundleAction; QPointer m_addToContentLibAction; + QPointer m_materialsAction; QHash> m_matOverrideActions; QPointer m_createSubMenu; ModelNode m_contextMenuTarget; diff --git a/src/plugins/qmldesigner/components/materialeditor/materialeditorview.cpp b/src/plugins/qmldesigner/components/materialeditor/materialeditorview.cpp index ee2d9123ce8..c40d9f1b322 100644 --- a/src/plugins/qmldesigner/components/materialeditor/materialeditorview.cpp +++ b/src/plugins/qmldesigner/components/materialeditor/materialeditorview.cpp @@ -24,6 +24,7 @@ #include "qmldesignerplugin.h" #include "qmltimeline.h" #include "variantproperty.h" +#include #include #include @@ -380,34 +381,14 @@ void MaterialEditorView::applyMaterialToSelectedModels(const ModelNode &material QTC_ASSERT(material.isValid(), return); - auto expToList = [](const QString &exp) { - QString copy = exp; - copy = copy.remove("[").remove("]"); - - QStringList tmp = copy.split(',', Qt::SkipEmptyParts); - for (QString &str : tmp) - str = str.trimmed(); - - return tmp; - }; - - auto listToExp = [](QStringList &stringList) { - if (stringList.size() > 1) - return QString("[" + stringList.join(",") + "]"); - - if (stringList.size() == 1) - return stringList.first(); - - return QString(); - }; - executeInTransaction(__FUNCTION__, [&] { for (const ModelNode &node : std::as_const(m_selectedModels)) { QmlObjectNode qmlObjNode(node); if (add) { - QStringList matList = expToList(qmlObjNode.expression("materials")); + QStringList matList = ModelUtils::expressionToList( + qmlObjNode.expression("materials")); matList.append(material.id()); - QString updatedExp = listToExp(matList); + QString updatedExp = ModelUtils::listToExpression(matList); qmlObjNode.setBindingProperty("materials", updatedExp); } else { qmlObjNode.setBindingProperty("materials", material.id()); diff --git a/src/plugins/qmldesigner/designercore/designercoreutils/modelutils.cpp b/src/plugins/qmldesigner/designercore/designercoreutils/modelutils.cpp index afa9769351c..e392bffe730 100644 --- a/src/plugins/qmldesigner/designercore/designercoreutils/modelutils.cpp +++ b/src/plugins/qmldesigner/designercore/designercoreutils/modelutils.cpp @@ -469,4 +469,33 @@ bool isValidQmlIdentifier(QStringView id) return id.contains(idExpr); } +QStringList expressionToList(QStringView expression) +{ + QStringView cleanedExp = (expression.startsWith('[') && expression.endsWith(']')) + ? expression.sliced(1, expression.size() - 2) + : expression; + QList tokens = cleanedExp.split(','); + + QStringList expList; + expList.reserve(tokens.size()); + for (QStringView token : tokens) { + token = token.trimmed(); + if (!token.isEmpty()) + expList.append(token.toString()); + } + + return expList; +} + +QString listToExpression(const QStringList &stringList) +{ + if (stringList.size() > 1) + return QString("[" + stringList.join(",") + "]"); + + if (stringList.size() == 1) + return stringList.first(); + + return QString(); +} + } // namespace QmlDesigner::ModelUtils diff --git a/src/plugins/qmldesigner/designercore/designercoreutils/modelutils.h b/src/plugins/qmldesigner/designercore/designercoreutils/modelutils.h index 6d8e08cca34..450a4fa7dfb 100644 --- a/src/plugins/qmldesigner/designercore/designercoreutils/modelutils.h +++ b/src/plugins/qmldesigner/designercore/designercoreutils/modelutils.h @@ -56,4 +56,9 @@ constexpr std::u16string_view toStdStringView(QStringView view) return {view.utf16(), Utils::usize(view)}; } +QMLDESIGNERCORE_EXPORT QStringList expressionToList(QStringView exp); + +QMLDESIGNERCORE_EXPORT QString listToExpression(const QStringList &stringList); +; + } // namespace QmlDesigner::ModelUtils diff --git a/tests/unit/tests/unittests/designercoreutils/modelutils-test.cpp b/tests/unit/tests/unittests/designercoreutils/modelutils-test.cpp index cbc19dd9d72..24393accd04 100644 --- a/tests/unit/tests/unittests/designercoreutils/modelutils-test.cpp +++ b/tests/unit/tests/unittests/designercoreutils/modelutils-test.cpp @@ -548,4 +548,157 @@ TEST(ModelUtils, empty_is_not_banned_Qml_id) ASSERT_THAT(isBannedQmlId, IsFalse()); } +TEST(ModelUtils, expressionToList_empty_expression_returns_empty_list) +{ + QString expression = ""; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, IsEmpty()); +} + +TEST(ModelUtils, expressionToList_empty_array_returns_empty_list) +{ + QString expression = "[]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, IsEmpty()); +} + +TEST(ModelUtils, expressionToList_comma_only_array_returns_empty_list) +{ + QString expression = "[,,,]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, IsEmpty()); +} + +TEST(ModelUtils, expressionToList_space_only_array_returns_empty_list) +{ + QString expression = "[ , , , ]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, IsEmpty()); +} + +TEST(ModelUtils, expressionToList_single_expression_returns_single_item_list) +{ + QString expression = "aaa"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("aaa")); +} + +TEST(ModelUtils, expressionToList_single_expression_keeps_middle_spaces) +{ + QString expression = "aa a b"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("aa a b")); +} + +TEST(ModelUtils, expressionToList_single_expression_omites_side_spaces) +{ + QString expression = " aa a b "; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("aa a b")); +} + +TEST(ModelUtils, expressionToList_single_item_array_returns_single_item_list) +{ + QString expression = "[aaa]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("aaa")); +} + +TEST(ModelUtils, expressionToList_array_with_multiple_items_returns_all) +{ + QString expression = "[bbb,aaa,ccc]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("bbb", "aaa", "ccc")); +} + +TEST(ModelUtils, expressionToList_array_with_empty_items_returns_clean_list) +{ + QString expression = "[,aaa,,bbb,ccc,,]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("aaa", "bbb", "ccc")); +} + +TEST(ModelUtils, expressionToList_keeps_middle_spaces_in_tokens) +{ + QString expression = "[aaa,,a bb b,ccc]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("aaa", "a bb b", "ccc")); +} + +TEST(ModelUtils, expressionToList_omits_side_spaces_in_tokens) +{ + QString expression = "[aaa,, a bb b ,ccc]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("aaa", "a bb b", "ccc")); +} + +TEST(ModelUtils, expressionToList_unicodes_supported) +{ + QString expression = "[你好, foo]"; + + QStringList list = QmlDesigner::ModelUtils::expressionToList(expression); + + ASSERT_THAT(list, UnorderedElementsAre("你好", "foo")); +} + +TEST(ModelUtils, listToExpression_returns_non_array_expression_for_single_items) +{ + QStringList list = {"aaa"}; + + QString expression = QmlDesigner::ModelUtils::listToExpression(list); + + ASSERT_THAT(expression, Eq("aaa")); +} + +TEST(ModelUtils, listToExpression_returns_expression) +{ + QStringList list = {"aaa", "bbb", "ccc"}; + + QString expression = QmlDesigner::ModelUtils::listToExpression(list); + + ASSERT_THAT(expression, Eq("[aaa,bbb,ccc]")); +} + +TEST(ModelUtils, listToExpression_returns_expression_with_empty_items) +{ + QStringList list = {"aaa", "bbb", "", "ccc"}; + + QString expression = QmlDesigner::ModelUtils::listToExpression(list); + + ASSERT_THAT(expression, Eq("[aaa,bbb,,ccc]")); +} + +TEST(ModelUtils, listToExpression_returns_empty_for_empty_expressions) +{ + QStringList list = {}; + + QString expression = QmlDesigner::ModelUtils::listToExpression(list); + + ASSERT_THAT(expression, IsEmpty()); +} + } // namespace