import QtQuick import QtQuick.Controls import QtQuick.Templates as T import qs.Commons Item { id: root // Signal for key press events when keyNavigationEnabled is true signal keyPressed(var event) property color handleColor: Qt.alpha(Color.mHover, 0.8) property color handleHoverColor: handleColor property color handlePressedColor: handleColor property color trackColor: "transparent" property real handleWidth: 6 property real handleRadius: Style.iRadiusM property int verticalPolicy: ScrollBar.AsNeeded property int horizontalPolicy: ScrollBar.AlwaysOff readonly property bool verticalScrollBarActive: { if (gridView.ScrollBar.vertical.policy === ScrollBar.AlwaysOff) return false; return gridView.contentHeight > gridView.height; } readonly property bool contentOverflows: gridView.contentHeight > gridView.height // Gradient properties property bool showGradientMasks: true property color gradientColor: Color.mSurfaceVariant property int gradientHeight: 16 property bool reserveScrollbarSpace: true // Keep scrollbars visible whenever overflow exists (without forcing visibility when not scrollable) property bool showScrollbarWhenScrollable: Settings.data.ui.scrollbarAlwaysVisible // Available width for content (excludes scrollbar space when reserveScrollbarSpace is true) // Note: Always reserves space when enabled to avoid binding loops with cellWidth calculations readonly property real availableWidth: width - (reserveScrollbarSpace ? handleWidth + Style.marginXS : 0) // Expose activeFocus from internal gridView readonly property bool hasActiveFocus: gridView.activeFocus // Forward GridView properties property alias model: gridView.model property alias delegate: gridView.delegate property alias cellWidth: gridView.cellWidth property alias cellHeight: gridView.cellHeight property alias leftMargin: gridView.leftMargin property alias rightMargin: gridView.rightMargin property alias topMargin: gridView.topMargin property alias bottomMargin: gridView.bottomMargin property alias currentIndex: gridView.currentIndex property alias count: gridView.count property alias contentHeight: gridView.contentHeight property alias contentWidth: gridView.contentWidth property alias contentY: gridView.contentY property alias contentX: gridView.contentX property alias currentItem: gridView.currentItem property alias highlightItem: gridView.highlightItem property alias highlightFollowsCurrentItem: gridView.highlightFollowsCurrentItem property alias preferredHighlightBegin: gridView.preferredHighlightBegin property alias preferredHighlightEnd: gridView.preferredHighlightEnd property alias highlightRangeMode: gridView.highlightRangeMode property alias snapMode: gridView.snapMode property alias keyNavigationEnabled: gridView.keyNavigationEnabled property alias keyNavigationWraps: gridView.keyNavigationWraps property alias cacheBuffer: gridView.cacheBuffer property alias displayMarginBeginning: gridView.displayMarginBeginning property alias displayMarginEnd: gridView.displayMarginEnd property alias layoutDirection: gridView.layoutDirection property alias effectiveLayoutDirection: gridView.effectiveLayoutDirection property alias flow: gridView.flow property alias boundsBehavior: gridView.boundsBehavior property alias flickableDirection: gridView.flickableDirection property alias interactive: gridView.interactive property alias moving: gridView.moving property alias flicking: gridView.flicking property alias dragging: gridView.dragging property alias horizontalVelocity: gridView.horizontalVelocity property alias verticalVelocity: gridView.verticalVelocity property alias reuseItems: gridView.reuseItems // Animate items when the model is reordered (e.g. ListModel.move()) property bool animateMovement: false // Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster) property real wheelScrollMultiplier: 2.0 property int smoothWheelAnimationDuration: Style.animationNormal property real _wheelTargetY: 0 function clampScrollY(value) { return Math.max(0, Math.min(value, gridView.contentHeight - gridView.height)); } function applyWheelScroll(delta) { if (!root.contentOverflows) return; const step = delta * root.wheelScrollMultiplier; if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled) { gridView.contentY = root.clampScrollY(gridView.contentY - step); root._wheelTargetY = gridView.contentY; return; } if (!wheelScrollAnimation.running) root._wheelTargetY = gridView.contentY; root._wheelTargetY = root.clampScrollY(root._wheelTargetY - step); wheelScrollAnimation.to = root._wheelTargetY; wheelScrollAnimation.restart(); } function animateToContentY(targetY) { const clampedY = root.clampScrollY(targetY); if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled || gridView.dragging || gridView.flicking) { gridView.contentY = clampedY; root._wheelTargetY = clampedY; return; } root._wheelTargetY = clampedY; wheelScrollAnimation.to = clampedY; wheelScrollAnimation.restart(); } // Track selection index for gradient visibility (set externally) property int trackedSelectionIndex: -1 // Check if selection is on first visible row readonly property bool selectionOnFirstVisibleRow: { if (trackedSelectionIndex < 0 || cellHeight <= 0 || cellWidth <= 0) return false; // Calculate columns per row var cols = Math.floor(gridView.width / cellWidth); if (cols <= 0) cols = 1; // Calculate the row of the selection var selectionRow = Math.round(trackedSelectionIndex / cols); // Calculate the first visible row var firstVisibleRow = Math.round(gridView.contentY / cellHeight); return selectionRow === firstVisibleRow; } // Check if selection is on last visible row readonly property bool selectionOnLastVisibleRow: { if (trackedSelectionIndex < 0 || cellHeight <= 0 || cellWidth <= 0) return false; // Calculate columns per row var cols = Math.floor(gridView.width / cellWidth); if (cols <= 0) cols = 1; // Calculate the row of the selection var selectionRow = Math.round(trackedSelectionIndex / cols); // Calculate the last visible row (might be partially visible) var lastVisibleRow = Math.round((gridView.contentY + gridView.height - 1) / cellHeight); return selectionRow === lastVisibleRow; } // Forward GridView methods function positionViewAtIndex(index, mode) { const shouldAnimate = mode === GridView.Contain; if (!shouldAnimate) { gridView.positionViewAtIndex(index, mode); root._wheelTargetY = gridView.contentY; return; } const previousY = gridView.contentY; gridView.positionViewAtIndex(index, mode); const targetY = root.clampScrollY(gridView.contentY); if (Math.abs(targetY - previousY) < 0.5) { root._wheelTargetY = targetY; return; } gridView.contentY = previousY; root._wheelTargetY = previousY; root.animateToContentY(targetY); } function positionViewAtBeginning() { gridView.positionViewAtBeginning(); } function positionViewAtEnd() { gridView.positionViewAtEnd(); } function forceLayout() { gridView.forceLayout(); } function forceActiveFocus() { gridView.forceActiveFocus(); } function cancelFlick() { gridView.cancelFlick(); } function flick(xVelocity, yVelocity) { gridView.flick(xVelocity, yVelocity); } function incrementCurrentIndex() { gridView.incrementCurrentIndex(); } function decrementCurrentIndex() { gridView.decrementCurrentIndex(); } function indexAt(x, y) { return gridView.indexAt(x, y); } function itemAt(x, y) { return gridView.itemAt(x, y); } function itemAtIndex(index) { return gridView.itemAtIndex(index); } function moveCurrentIndexUp() { gridView.moveCurrentIndexUp(); } function moveCurrentIndexDown() { gridView.moveCurrentIndexDown(); } function moveCurrentIndexLeft() { gridView.moveCurrentIndexLeft(); } function moveCurrentIndexRight() { gridView.moveCurrentIndexRight(); } // Set reasonable implicit sizes for Layout usage implicitWidth: 200 implicitHeight: 200 Component.onCompleted: { _wheelTargetY = gridView.contentY; createGradients(); } // Dynamically create gradient overlays function createGradients() { if (!showGradientMasks) return; Qt.createQmlObject(` import QtQuick import qs.Commons Rectangle { x: 0 y: 0 width: root.availableWidth height: root.gradientHeight z: 1 visible: root.showGradientMasks && root.contentOverflows opacity: (gridView.contentY <= 1 || root.selectionOnFirstVisibleRow) ? 0 : 1 Behavior on opacity { NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } } gradient: Gradient { GradientStop { position: 0.0; color: root.gradientColor } GradientStop { position: 1.0; color: "transparent" } } } `, root, "topGradient"); Qt.createQmlObject(` import QtQuick import qs.Commons Rectangle { x: 0 anchors.bottom: parent.bottom anchors.bottomMargin: -1 width: root.availableWidth height: root.gradientHeight + 1 z: 1 visible: root.showGradientMasks && root.contentOverflows opacity: ((gridView.contentY + gridView.height >= gridView.contentHeight - 1) || root.selectionOnLastVisibleRow) ? 0 : 1 Behavior on opacity { NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } } gradient: Gradient { GradientStop { position: 0.0; color: "transparent" } GradientStop { position: 1.0; color: root.gradientColor } } } `, root, "bottomGradient"); } GridView { id: gridView anchors.fill: parent anchors.rightMargin: root.reserveScrollbarSpace ? root.handleWidth + Style.marginXS : 0 move: root.animateMovement ? moveTransitionImpl : null displaced: root.animateMovement ? displacedTransitionImpl : null Transition { id: moveTransitionImpl NumberAnimation { properties: "x,y" duration: Style.animationNormal easing.type: Easing.InOutQuad } } Transition { id: displacedTransitionImpl NumberAnimation { properties: "x,y" duration: Style.animationNormal easing.type: Easing.InOutQuad } } // Enable clipping to keep content within bounds clip: true // Enable flickable for smooth scrolling boundsBehavior: Flickable.StopAtBounds NumberAnimation { id: wheelScrollAnimation target: gridView property: "contentY" duration: root.smoothWheelAnimationDuration easing.type: Easing.OutCubic } onDraggingChanged: { if (dragging) { wheelScrollAnimation.stop(); root._wheelTargetY = contentY; } } onFlickingChanged: { if (flicking) { wheelScrollAnimation.stop(); root._wheelTargetY = contentY; } } onContentHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY) onHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY) // Focus handling depends on keyNavigationEnabled focus: keyNavigationEnabled activeFocusOnTab: keyNavigationEnabled // Emit keyPressed signal for custom key handling Keys.onPressed: event => { if (keyNavigationEnabled) { root.keyPressed(event); } } WheelHandler { enabled: root.wheelScrollMultiplier !== 1.0 acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: event => { const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2; root.applyWheelScroll(delta); event.accepted = true; } } ScrollBar.vertical: ScrollBar { parent: root x: root.mirrored ? 0 : root.width - width y: 0 height: root.height policy: root.verticalPolicy contentItem: Rectangle { implicitWidth: root.handleWidth implicitHeight: 100 radius: root.handleRadius color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0 Behavior on opacity { NumberAnimation { duration: Style.animationFast } } Behavior on color { ColorAnimation { duration: Style.animationFast } } } background: Rectangle { implicitWidth: root.handleWidth implicitHeight: 100 color: root.trackColor opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0 radius: root.handleRadius / 2 Behavior on opacity { NumberAnimation { duration: Style.animationFast } } } } } }