// Copyright (C) 2023 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 import QtQuick import HelperWidgets as HelperWidgets import StudioControls as StudioControls import StudioTheme as StudioTheme import MaterialBrowserBackend Item { id: root focus: true readonly property real cellWidth: root.thumbnailSize readonly property real cellHeight: root.thumbnailSize + 20 readonly property bool enableUiElements: materialBrowserModel.hasMaterialLibrary && materialBrowserModel.hasQuick3DImport property var currMaterialItem: null property var rootView: MaterialBrowserBackend.rootView property var materialBrowserModel: MaterialBrowserBackend.materialBrowserModel property var materialBrowserTexturesModel: MaterialBrowserBackend.materialBrowserTexturesModel property int numColumns: 0 property real thumbnailSize: 100 readonly property int minThumbSize: 100 readonly property int maxThumbSize: 150 function responsiveResize(width: int, height: int) { width -= 2 * StudioTheme.Values.sectionPadding let numColumns = Math.floor(width / root.minThumbSize) let remainder = width % root.minThumbSize let space = (numColumns - 1) * StudioTheme.Values.sectionGridSpacing if (remainder < space) numColumns -= 1 if (numColumns < 1) return let maxItems = Math.max(texturesRepeater.count, materialRepeater.count) if (numColumns > maxItems) numColumns = maxItems let rest = width - (numColumns * root.minThumbSize) - ((numColumns - 1) * StudioTheme.Values.sectionGridSpacing) root.thumbnailSize = Math.min(root.minThumbSize + (rest / numColumns), root.maxThumbSize) root.numColumns = numColumns } onWidthChanged: root.responsiveResize(root.width, root.height) // Called also from C++ to close context menu on focus out function closeContextMenu() { ctxMenu.close() ctxMenuTextures.close() HelperWidgets.Controller.closeContextMenu() } // Called from C++ to refresh a preview material after it changes function refreshPreview(idx) { var item = materialRepeater.itemAt(idx); if (item) item.refreshPreview() } // Called from C++ function clearSearchFilter() { searchBox.clear() } function nextVisibleItem(idx, count, itemModel) { if (count === 0) return idx let pos = 0 let newIdx = idx let direction = 1 if (count < 0) direction = -1 while (pos !== count) { newIdx += direction if (newIdx < 0 || newIdx >= itemModel.rowCount()) return -1 if (itemModel.isVisible(newIdx)) pos += direction } return newIdx } function visibleItemCount(itemModel) { let curIdx = 0 let count = 0 for (; curIdx < itemModel.rowCount(); ++curIdx) { if (itemModel.isVisible(curIdx)) ++count } return count } function rowIndexOfItem(idx, rowSize, itemModel) { if (rowSize === 1) return 1 let curIdx = 0 let count = -1 while (curIdx <= idx) { if (curIdx >= itemModel.rowCount()) break if (itemModel.isVisible(curIdx)) ++count ++curIdx } return count % rowSize } function selectNextVisibleItem(delta) { if (searchBox.activeFocus) return let targetIdx = -1 let newTargetIdx = -1 let origRowIdx = -1 let rowIdx = -1 let matSecFocused = rootView.materialSectionFocused && materialsSection.expanded let texSecFocused = !rootView.materialSectionFocused && texturesSection.expanded if (delta < 0) { if (matSecFocused) { targetIdx = root.nextVisibleItem(materialBrowserModel.selectedIndex, delta, materialBrowserModel) if (targetIdx >= 0) materialBrowserModel.selectMaterial(targetIdx) } else if (texSecFocused) { targetIdx = root.nextVisibleItem(materialBrowserTexturesModel.selectedIndex, delta, materialBrowserTexturesModel) if (targetIdx >= 0) { materialBrowserTexturesModel.selectTexture(targetIdx) } else if (!materialBrowserModel.isEmpty && materialsSection.expanded) { targetIdx = root.nextVisibleItem(materialBrowserModel.rowCount(), -1, materialBrowserModel) if (targetIdx >= 0) { if (delta !== -1) { // Try to match column when switching between materials/textures origRowIdx = root.rowIndexOfItem(materialBrowserTexturesModel.selectedIndex, -delta, materialBrowserTexturesModel) if (root.visibleItemCount(materialBrowserModel) > origRowIdx) { rowIdx = root.rowIndexOfItem(targetIdx, -delta, materialBrowserModel) if (rowIdx >= origRowIdx) { newTargetIdx = root.nextVisibleItem(targetIdx, -(rowIdx - origRowIdx), materialBrowserModel) } else { newTargetIdx = root.nextVisibleItem(targetIdx, -(-delta - origRowIdx + rowIdx), materialBrowserModel) } } else { newTargetIdx = root.nextVisibleItem(materialBrowserModel.rowCount(), -1, materialBrowserModel) } if (newTargetIdx >= 0) targetIdx = newTargetIdx } materialBrowserModel.selectMaterial(targetIdx) rootView.focusMaterialSection(true) } } } } else if (delta > 0) { if (matSecFocused) { targetIdx = root.nextVisibleItem(materialBrowserModel.selectedIndex, delta, materialBrowserModel) if (targetIdx >= 0) { materialBrowserModel.selectMaterial(targetIdx) } else if (!materialBrowserTexturesModel.isEmpty && texturesSection.expanded) { targetIdx = root.nextVisibleItem(-1, 1, materialBrowserTexturesModel) if (targetIdx >= 0) { if (delta !== 1) { // Try to match column when switching between materials/textures origRowIdx = root.rowIndexOfItem(materialBrowserModel.selectedIndex, delta, materialBrowserModel) if (root.visibleItemCount(materialBrowserTexturesModel) > origRowIdx) { if (origRowIdx > 0) { newTargetIdx = root.nextVisibleItem(targetIdx, origRowIdx, materialBrowserTexturesModel) } } else { newTargetIdx = root.nextVisibleItem(materialBrowserTexturesModel.rowCount(), -1, materialBrowserTexturesModel) } if (newTargetIdx >= 0) targetIdx = newTargetIdx } materialBrowserTexturesModel.selectTexture(targetIdx) rootView.focusMaterialSection(false) } } } else if (texSecFocused) { targetIdx = root.nextVisibleItem(materialBrowserTexturesModel.selectedIndex, delta, materialBrowserTexturesModel) if (targetIdx >= 0) materialBrowserTexturesModel.selectTexture(targetIdx) } } } Keys.enabled: true Keys.onDownPressed: root.selectNextVisibleItem(gridMaterials.columns) Keys.onUpPressed: root.selectNextVisibleItem(-gridMaterials.columns) Keys.onLeftPressed: root.selectNextVisibleItem(-1) Keys.onRightPressed: root.selectNextVisibleItem(1) function handleEnterPress() { if (searchBox.activeFocus) return if (!materialBrowserModel.isEmpty && rootView.materialSectionFocused && materialsSection.expanded) materialBrowserModel.openMaterialEditor() else if (!materialBrowserTexturesModel.isEmpty && !rootView.materialSectionFocused && texturesSection.expanded) materialBrowserTexturesModel.openTextureEditor() } Keys.onEnterPressed: root.handleEnterPress() Keys.onReturnPressed: root.handleEnterPress() MouseArea { id: focusGrabber y: searchBox.height width: parent.width height: parent.height - searchBox.height acceptedButtons: Qt.LeftButton | Qt.RightButton onPressed: (mouse) => { forceActiveFocus() // Steal focus from name edit mouse.accepted = false } z: 1 } MouseArea { id: rootMouseArea y: toolbar.height width: parent.width height: parent.height - toolbar.height acceptedButtons: Qt.RightButton onClicked: (mouse) => { if (!root.enableUiElements) return var matsSecBottom = mapFromItem(materialsSection, 0, materialsSection.y).y + materialsSection.height if (mouse.y < matsSecBottom) ctxMenu.popupMenu() else ctxMenuTextures.popupMenu() } } function ensureVisible(yPos, itemHeight) { let currentY = contentYBehavior.targetValue && scrollViewAnim.running ? contentYBehavior.targetValue : scrollView.contentY if (currentY > yPos) { if (yPos < itemHeight) scrollView.contentY = 0 else scrollView.contentY = yPos return true } else { let adjustedY = yPos + itemHeight - scrollView.height + 8 if (currentY < adjustedY) { if (scrollView.contentHeight - scrollView.height < adjustedY ) scrollView.contentY = scrollView.contentHeight - scrollView.height else scrollView.contentY = adjustedY return true } } return false } function ensureSelectedVisible() { if (rootView.materialSectionFocused && materialsSection.expanded && root.currMaterialItem && materialBrowserModel.isVisible(materialBrowserModel.selectedIndex)) { return root.ensureVisible(root.currMaterialItem.mapToItem(scrollView.contentItem, 0, 0).y, root.currMaterialItem.height) } else if (!rootView.materialSectionFocused && texturesSection.expanded) { let currItem = texturesRepeater.itemAt(materialBrowserTexturesModel.selectedIndex) if (currItem && materialBrowserTexturesModel.isVisible(materialBrowserTexturesModel.selectedIndex)) return root.ensureVisible(currItem.mapToItem(scrollView.contentItem, 0, 0).y, currItem.height) } else { return root.ensureVisible(0, 90) } } Timer { id: ensureTimer interval: 20 repeat: true triggeredOnStart: true onTriggered: { // Redo until ensuring didn't change things if (!root.ensureSelectedVisible()) { ensureTimer.stop() ensureTimer.interval = 20 ensureTimer.triggeredOnStart = true } } } function startDelayedEnsureTimer(delay) { // Ensuring visibility immediately in some cases like before new search results are rendered // causes mapToItem return incorrect values, leading to undesirable flicker, // so delay ensuring visibility a bit. ensureTimer.interval = delay ensureTimer.triggeredOnStart = false ensureTimer.restart() } Connections { target: materialBrowserModel function onSelectedIndexChanged() { // commit rename upon changing selection if (root.currMaterialItem) root.currMaterialItem.forceFinishEditing(); root.currMaterialItem = materialRepeater.itemAt(materialBrowserModel.selectedIndex); ensureTimer.start() } function onIsEmptyChanged() { ensureTimer.start() } } Connections { target: materialBrowserTexturesModel function onSelectedIndexChanged() { ensureTimer.start() } function onIsEmptyChanged() { ensureTimer.start() } } Connections { target: rootView function onMaterialSectionFocusedChanged() { ensureTimer.start() } } MaterialBrowserContextMenu { id: ctxMenu onClosed: { if (restoreFocusOnClose) scrollView.forceActiveFocus() } } TextureBrowserContextMenu { id: ctxMenuTextures onClosed: { scrollView.forceActiveFocus() } } component DoubleButton: Rectangle { id: doubleButton signal clicked() property alias icon: iconLabel.text property alias tooltip: mouseArea.tooltip property StudioTheme.ControlStyle style: StudioTheme.Values.viewBarButtonStyle width: doubleButton.style.squareControlSize.width * 2 height: doubleButton.style.squareControlSize.height radius: StudioTheme.Values.smallRadius Row { id: contentRow spacing: 0 Text { id: iconLabel width: doubleButton.style.squareControlSize.width height: doubleButton.height text: StudioTheme.Constants.material_medium font.family: StudioTheme.Constants.iconFont.family font.pixelSize: doubleButton.style.baseIconFontSize horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } Text { id: plusLabel width: doubleButton.style.squareControlSize.width height: doubleButton.height text: StudioTheme.Constants.add_medium font.family: StudioTheme.Constants.iconFont.family font.pixelSize: doubleButton.style.baseIconFontSize horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } HelperWidgets.ToolTipArea { id: mouseArea anchors.fill: parent onClicked: doubleButton.clicked() } states: [ State { name: "default" when: doubleButton.enabled && !mouseArea.containsMouse && !mouseArea.pressed PropertyChanges { target: doubleButton color: doubleButton.style.background.idle border.color: doubleButton.style.border.idle } PropertyChanges { target: iconLabel color: doubleButton.style.icon.idle } PropertyChanges { target: plusLabel color: doubleButton.style.icon.idle } }, State { name: "hover" when: doubleButton.enabled && mouseArea.containsMouse && !mouseArea.pressed PropertyChanges { target: doubleButton color: doubleButton.style.background.hover border.color: doubleButton.style.border.hover } PropertyChanges { target: iconLabel color: doubleButton.style.icon.hover } PropertyChanges { target: plusLabel color: doubleButton.style.icon.hover } }, State { name: "pressed" when: doubleButton.enabled && mouseArea.containsMouse && mouseArea.pressed PropertyChanges { target: doubleButton color: doubleButton.style.interaction border.color: doubleButton.style.interaction } PropertyChanges { target: iconLabel color: doubleButton.style.icon.interaction } PropertyChanges { target: plusLabel color: doubleButton.style.icon.interaction } }, State { name: "pressedButNotHovered" when: doubleButton.enabled && !mouseArea.containsMouse && mouseArea.pressed extend: "hover" }, State { name: "disable" when: !doubleButton.enabled PropertyChanges { target: doubleButton color: doubleButton.style.background.disabled border.color: doubleButton.style.border.disabled } PropertyChanges { target: iconLabel color: doubleButton.style.icon.disabled } PropertyChanges { target: plusLabel color: doubleButton.style.icon.disabled } } ] } Column { id: col anchors.fill: parent spacing: 5 Rectangle { id: toolbar width: parent.width height: StudioTheme.Values.doubleToolbarHeight color: StudioTheme.Values.themeToolbarBackground Column { anchors.fill: parent anchors.topMargin: StudioTheme.Values.toolbarVerticalMargin anchors.bottomMargin: StudioTheme.Values.toolbarVerticalMargin anchors.leftMargin: StudioTheme.Values.toolbarHorizontalMargin anchors.rightMargin: StudioTheme.Values.toolbarHorizontalMargin spacing: StudioTheme.Values.toolbarColumnSpacing StudioControls.SearchBox { id: searchBox width: parent.width style: StudioTheme.Values.searchControlStyle property string previousSearchText: "" property bool materialsExpanded: true property bool texturesExpanded: true onSearchChanged: (searchText) => { if (searchText !== "") { if (previousSearchText === "") { materialsExpanded = materialsSection.expanded texturesExpanded = texturesSection.expanded } materialsSection.expanded = true texturesSection.expanded = true } else if (previousSearchText !== "") { materialsSection.expanded = materialsExpanded texturesSection.expanded = texturesExpanded } previousSearchText = searchText root.startDelayedEnsureTimer(50) rootView.handleSearchFilterChanged(searchText) } } Row { width: parent.width height: StudioTheme.Values.toolbarHeight spacing: 6 DoubleButton { id: addMaterial icon: StudioTheme.Constants.material_medium tooltip: qsTr("Add a Material.") onClicked: materialBrowserModel.addNewMaterial() enabled: root.enableUiElements } DoubleButton { id: addTexture icon: StudioTheme.Constants.textures_medium tooltip: qsTr("Add a Texture.") onClicked: materialBrowserTexturesModel.addNewTexture() enabled: root.enableUiElements } } } } Item { width: root.width height: root.height - toolbar.height visible: hint.text !== "" Text { id: hint width: parent.width - 40 anchors.centerIn: parent text: { if (!materialBrowserModel.isQt6Project) qsTr("Material Browser is not supported in Qt5 projects.") else if (!materialBrowserModel.hasQuick3DImport) qsTr("To use Material Browser, first add the QtQuick3D module in the Components view.") else if (!materialBrowserModel.hasMaterialLibrary) qsTr("Material Browser is disabled inside a non-visual component.") else "" } textFormat: Text.RichText color: StudioTheme.Values.themeTextColor font.pixelSize: StudioTheme.Values.mediumFontSize horizontalAlignment: Text.AlignHCenter wrapMode: Text.WordWrap } } HelperWidgets.ScrollView { id: scrollView width: root.width height: root.height - toolbar.height - col.spacing clip: true visible: root.enableUiElements hideHorizontalScrollBar: true interactive: !ctxMenu.opened && !ctxMenuTextures.opened && !rootView.isDragging && !HelperWidgets.Controller.contextMenuOpened Behavior on contentY { id: contentYBehavior PropertyAnimation { id: scrollViewAnim easing.type: Easing.InOutQuad } } Column { Item { width: root.width height: materialsSection.height HelperWidgets.Section { id: materialsSection width: root.width leftPadding: StudioTheme.Values.sectionPadding rightPadding: StudioTheme.Values.sectionPadding topPadding: StudioTheme.Values.sectionPadding bottomPadding: StudioTheme.Values.sectionPadding caption: qsTr("Materials") dropEnabled: true category: "MaterialBrowser" onDropEnter: (drag) => { drag.accepted = drag.formats[0] === "application/vnd.qtdesignstudio.bundlematerial" materialsSection.highlight = drag.accepted } onDropExit: { materialsSection.highlight = false } onDrop: (drag) => { drag.accept() materialsSection.highlight = false rootView.acceptBundleMaterialDrop() } onExpandedChanged: { if (expanded) { if (root.visibleItemCount(materialBrowserModel) > 0) rootView.focusMaterialSection(true) if (!searchBox.activeFocus) scrollView.forceActiveFocus() } else { root.startDelayedEnsureTimer(300) // wait for section collapse animation rootView.focusMaterialSection(false) } } Grid { id: gridMaterials width: scrollView.width - materialsSection.leftPadding - materialsSection.rightPadding spacing: StudioTheme.Values.sectionGridSpacing columns: root.numColumns Repeater { id: materialRepeater model: materialBrowserModel onItemRemoved: (index, item) => { if (item === root.currMaterialItem) root.currMaterialItem = null } delegate: MaterialItem { width: root.cellWidth height: root.cellHeight onShowContextMenu: ctxMenu.popupMenu(this, model) } onCountChanged: root.responsiveResize(root.width, root.height) } } Text { text: qsTr("No match found."); color: StudioTheme.Values.themeTextColor font.pixelSize: StudioTheme.Values.baseFontSize leftPadding: 10 visible: materialBrowserModel.isEmpty && !searchBox.isEmpty() } Text { text:qsTr("There are no materials in this project.
Select '+' to create one.") visible: materialBrowserModel.isEmpty && searchBox.isEmpty() textFormat: Text.RichText color: StudioTheme.Values.themeTextColor font.pixelSize: StudioTheme.Values.mediumFontSize horizontalAlignment: Text.AlignHCenter wrapMode: Text.WordWrap width: root.width } } } Item { width: root.width height: texturesSection.height HelperWidgets.Section { id: texturesSection width: root.width leftPadding: StudioTheme.Values.sectionPadding rightPadding: StudioTheme.Values.sectionPadding topPadding: StudioTheme.Values.sectionPadding bottomPadding: StudioTheme.Values.sectionPadding caption: qsTr("Textures") category: "MaterialBrowser" dropEnabled: true onDropEnter: (drag) => { let accepted = drag.formats[0] === "application/vnd.qtdesignstudio.bundletexture" if (drag.formats[0] === "application/vnd.qtdesignstudio.assets") accepted = rootView.hasAcceptableAssets(drag.urls) drag.accepted = accepted highlight = drag.accepted } onDropExit: { highlight = false } onDrop: (drag) => { drag.accept() highlight = false if (drag.formats[0] === "application/vnd.qtdesignstudio.bundletexture") rootView.acceptBundleTextureDrop() else if (drag.formats[0] === "application/vnd.qtdesignstudio.assets") rootView.acceptAssetsDrop(drag.urls) } onExpandedChanged: { if (expanded) { if (root.visibleItemCount(materialBrowserTexturesModel) > 0) rootView.focusMaterialSection(false) if (!searchBox.activeFocus) scrollView.forceActiveFocus() } else { root.startDelayedEnsureTimer(300) // wait for section collapse animation rootView.focusMaterialSection(true) } } Grid { id: gridTextures width: scrollView.width - texturesSection.leftPadding - texturesSection.rightPadding spacing: StudioTheme.Values.sectionGridSpacing columns: root.numColumns Repeater { id: texturesRepeater model: materialBrowserTexturesModel delegate: TextureItem { width: root.cellWidth height: root.cellHeight onShowContextMenu: ctxMenuTextures.popupMenu(model) } onCountChanged: root.responsiveResize(root.width, root.height) } } Text { text: qsTr("No match found."); color: StudioTheme.Values.themeTextColor font.pixelSize: StudioTheme.Values.baseFontSize leftPadding: 10 visible: materialBrowserTexturesModel.isEmpty && !searchBox.isEmpty() } Text { text:qsTr("There are no textures in this project.") visible: materialBrowserTexturesModel.isEmpty && searchBox.isEmpty() textFormat: Text.RichText color: StudioTheme.Values.themeTextColor font.pixelSize: StudioTheme.Values.mediumFontSize horizontalAlignment: Text.AlignHCenter wrapMode: Text.WordWrap width: root.width } } } DropArea { id: masterDropArea property int emptyHeight: scrollView.height - materialsSection.height - texturesSection.height width: root.width height: emptyHeight > 0 ? emptyHeight : 0 enabled: true onEntered: (drag) => texturesSection.dropEnter(drag) onDropped: (drag) => texturesSection.drop(drag) onExited: texturesSection.dropExit() } } } } }