From 02ab8e2db59c1f171624e3375405447375e9803a Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 7 Aug 2025 12:00:24 -0400 Subject: [PATCH] scroll improvement --- Modals/SpotlightModal.qml | 307 ++++++++++++++++++++++++-- Modules/AppDrawer/AppDrawerPopout.qml | 302 ++++++++++++++++++++++++- Widgets/DankGridView.qml | 220 ------------------ Widgets/DankListView.qml | 219 ------------------ Widgets/GoodScrollingGridView.qml | 151 +++++++++++++ Widgets/GoodScrollingListView.qml | 151 +++++++++++++ 6 files changed, 883 insertions(+), 467 deletions(-) delete mode 100644 Widgets/DankGridView.qml delete mode 100644 Widgets/DankListView.qml create mode 100644 Widgets/GoodScrollingGridView.qml create mode 100644 Widgets/GoodScrollingListView.qml diff --git a/Modals/SpotlightModal.qml b/Modals/SpotlightModal.qml index 4e1af430..687a8d4d 100644 --- a/Modals/SpotlightModal.qml +++ b/Modals/SpotlightModal.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Effects import Quickshell import Quickshell.Io +import Quickshell.Widgets import qs.Common import qs.Modules.AppDrawer import qs.Services @@ -282,19 +283,50 @@ DankModal { border.color: Theme.outlineLight border.width: 1 - DankListView { + GoodScrollingListView { id: resultsList + property int itemHeight: 60 + property int iconSize: 40 + property bool showDescription: true + property int itemSpacing: Theme.spacingS + property bool hoverUpdatesSelection: false + property bool keyboardNavigationActive: appLauncher.keyboardNavigationActive + + signal keyboardNavigationReset() + signal itemClicked(int index, var modelData) + signal itemHovered(int index) + signal itemRightClicked(int index, var modelData, real mouseX, real mouseY) + + function ensureVisible(index) { + if (index < 0 || index >= count) + return; + + var itemY = index * (itemHeight + itemSpacing); + var itemBottom = itemY + itemHeight; + if (itemY < contentY) + contentY = itemY; + else if (itemBottom > contentY + height) + contentY = itemBottom - height; + } + anchors.fill: parent anchors.margins: Theme.spacingS visible: appLauncher.viewMode === "list" model: appLauncher.model currentIndex: appLauncher.selectedIndex - itemHeight: 60 - iconSize: 40 - showDescription: true - hoverUpdatesSelection: false - keyboardNavigationActive: appLauncher.keyboardNavigationActive + clip: true + spacing: itemSpacing + focus: true + interactive: true + cacheBuffer: Math.min(height * 2, 1000) + reuseItems: true + + onCurrentIndexChanged: { + if (keyboardNavigationActive) + ensureVisible(currentIndex); + } + onItemClicked: function(index, modelData) { appLauncher.launchApp(modelData); } @@ -307,24 +339,170 @@ DankModal { onKeyboardNavigationReset: { appLauncher.keyboardNavigationActive = false; } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AlwaysOn + } + + ScrollBar.horizontal: ScrollBar { + policy: ScrollBar.AlwaysOff + } + + delegate: Rectangle { + width: ListView.view.width + height: resultsList.itemHeight + radius: Theme.cornerRadiusLarge + color: ListView.isCurrentItem ? Theme.primaryPressed : mouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03) + border.color: ListView.isCurrentItem ? Theme.primarySelected : Theme.outlineMedium + border.width: ListView.isCurrentItem ? 2 : 1 + + Row { + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingL + + Item { + width: resultsList.iconSize + height: resultsList.iconSize + anchors.verticalCenter: parent.verticalCenter + + IconImage { + id: iconImg + + anchors.fill: parent + source: (model.icon) ? Quickshell.iconPath(model.icon, SettingsData.iconTheme === "System Default" ? "" : SettingsData.iconTheme) : "" + smooth: true + asynchronous: true + visible: status === Image.Ready + } + + Rectangle { + anchors.fill: parent + visible: !iconImg.visible + color: Theme.surfaceLight + radius: Theme.cornerRadiusLarge + border.width: 1 + border.color: Theme.primarySelected + + StyledText { + anchors.centerIn: parent + text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A" + font.pixelSize: resultsList.iconSize * 0.4 + color: Theme.primary + font.weight: Font.Bold + } + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - resultsList.iconSize - Theme.spacingL + spacing: Theme.spacingXS + + StyledText { + width: parent.width + text: model.name || "" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + elide: Text.ElideRight + } + + StyledText { + width: parent.width + text: model.comment || "Application" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + elide: Text.ElideRight + visible: resultsList.showDescription && model.comment && model.comment.length > 0 + } + } + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + z: 10 + onEntered: { + if (resultsList.hoverUpdatesSelection && !resultsList.keyboardNavigationActive) + resultsList.currentIndex = index; + + resultsList.itemHovered(index); + } + onPositionChanged: { + resultsList.keyboardNavigationReset(); + } + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + resultsList.itemClicked(index, model); + } else if (mouse.button === Qt.RightButton) { + var globalPos = mapToGlobal(mouse.x, mouse.y); + resultsList.itemRightClicked(index, model, globalPos.x, globalPos.y); + } + } + } + } } - DankGridView { + GoodScrollingGridView { id: resultsGrid + property int currentIndex: appLauncher.selectedIndex + property int columns: 4 + property bool adaptiveColumns: false + property int minCellWidth: 120 + property int maxCellWidth: 160 + property int cellPadding: 8 + property real iconSizeRatio: 0.55 + property int maxIconSize: 48 + property int minIconSize: 32 + property bool hoverUpdatesSelection: false + property bool keyboardNavigationActive: appLauncher.keyboardNavigationActive + property int baseCellWidth: adaptiveColumns ? Math.max(minCellWidth, Math.min(maxCellWidth, width / columns)) : (width - Theme.spacingS * 2) / columns + property int baseCellHeight: baseCellWidth + 20 + property int actualColumns: adaptiveColumns ? Math.floor(width / cellWidth) : columns + property int remainingSpace: width - (actualColumns * cellWidth) + + signal keyboardNavigationReset() + signal itemClicked(int index, var modelData) + signal itemHovered(int index) + signal itemRightClicked(int index, var modelData, real mouseX, real mouseY) + + function ensureVisible(index) { + if (index < 0 || index >= count) + return; + + var itemY = Math.floor(index / actualColumns) * cellHeight; + var itemBottom = itemY + cellHeight; + if (itemY < contentY) + contentY = itemY; + else if (itemBottom > contentY + height) + contentY = itemBottom - height; + } + anchors.fill: parent anchors.margins: Theme.spacingS visible: appLauncher.viewMode === "grid" model: appLauncher.model - columns: 4 - adaptiveColumns: false - minCellWidth: 120 - maxCellWidth: 160 - iconSizeRatio: 0.55 - maxIconSize: 48 - currentIndex: appLauncher.selectedIndex - hoverUpdatesSelection: false - keyboardNavigationActive: appLauncher.keyboardNavigationActive + clip: true + cellWidth: baseCellWidth + cellHeight: baseCellHeight + leftMargin: Math.max(Theme.spacingS, remainingSpace / 2) + rightMargin: leftMargin + focus: true + interactive: true + cacheBuffer: Math.min(height * 2, 1000) + reuseItems: true + + onCurrentIndexChanged: { + if (keyboardNavigationActive) + ensureVisible(currentIndex); + } + onItemClicked: function(index, modelData) { appLauncher.launchApp(modelData); } @@ -337,6 +515,103 @@ DankModal { onKeyboardNavigationReset: { appLauncher.keyboardNavigationActive = false; } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ScrollBar.horizontal: ScrollBar { + policy: ScrollBar.AlwaysOff + } + + delegate: Rectangle { + width: resultsGrid.cellWidth - resultsGrid.cellPadding + height: resultsGrid.cellHeight - resultsGrid.cellPadding + radius: Theme.cornerRadiusLarge + color: resultsGrid.currentIndex === index ? Theme.primaryPressed : mouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03) + border.color: resultsGrid.currentIndex === index ? Theme.primarySelected : Theme.outlineMedium + border.width: resultsGrid.currentIndex === index ? 2 : 1 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingS + + Item { + property int iconSize: Math.min(resultsGrid.maxIconSize, Math.max(resultsGrid.minIconSize, resultsGrid.cellWidth * resultsGrid.iconSizeRatio)) + + width: iconSize + height: iconSize + anchors.horizontalCenter: parent.horizontalCenter + + IconImage { + id: iconImg + + anchors.fill: parent + source: (model.icon) ? Quickshell.iconPath(model.icon, SettingsData.iconTheme === "System Default" ? "" : SettingsData.iconTheme) : "" + smooth: true + asynchronous: true + visible: status === Image.Ready + } + + Rectangle { + anchors.fill: parent + visible: !iconImg.visible + color: Theme.surfaceLight + radius: Theme.cornerRadiusLarge + border.width: 1 + border.color: Theme.primarySelected + + StyledText { + anchors.centerIn: parent + text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A" + font.pixelSize: Math.min(28, parent.width * 0.5) + color: Theme.primary + font.weight: Font.Bold + } + } + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + width: resultsGrid.cellWidth - 12 + text: model.name || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Medium + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + maximumLineCount: 2 + wrapMode: Text.WordWrap + } + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + z: 10 + onEntered: { + if (resultsGrid.hoverUpdatesSelection && !resultsGrid.keyboardNavigationActive) + resultsGrid.currentIndex = index; + + resultsGrid.itemHovered(index); + } + onPositionChanged: { + resultsGrid.keyboardNavigationReset(); + } + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + resultsGrid.itemClicked(index, model); + } else if (mouse.button === Qt.RightButton) { + var globalPos = mapToGlobal(mouse.x, mouse.y); + resultsGrid.itemRightClicked(index, model, globalPos.x, globalPos.y); + } + } + } + } } } diff --git a/Modules/AppDrawer/AppDrawerPopout.qml b/Modules/AppDrawer/AppDrawerPopout.qml index b7db8088..87c81928 100644 --- a/Modules/AppDrawer/AppDrawerPopout.qml +++ b/Modules/AppDrawer/AppDrawerPopout.qml @@ -368,19 +368,50 @@ PanelWindow { border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05) border.width: 1 - DankListView { + GoodScrollingListView { id: appList + property int itemHeight: 72 + property int iconSize: 56 + property bool showDescription: true + property int itemSpacing: Theme.spacingS + property bool hoverUpdatesSelection: false + property bool keyboardNavigationActive: appLauncher.keyboardNavigationActive + + signal keyboardNavigationReset() + signal itemClicked(int index, var modelData) + signal itemHovered(int index) + signal itemRightClicked(int index, var modelData, real mouseX, real mouseY) + + function ensureVisible(index) { + if (index < 0 || index >= count) + return; + + var itemY = index * (itemHeight + itemSpacing); + var itemBottom = itemY + itemHeight; + if (itemY < contentY) + contentY = itemY; + else if (itemBottom > contentY + height) + contentY = itemBottom - height; + } + anchors.fill: parent anchors.margins: Theme.spacingS visible: appLauncher.viewMode === "list" model: appLauncher.model currentIndex: appLauncher.selectedIndex - itemHeight: 72 - iconSize: 56 - showDescription: true - hoverUpdatesSelection: false - keyboardNavigationActive: appLauncher.keyboardNavigationActive + clip: true + spacing: itemSpacing + focus: true + interactive: true + cacheBuffer: Math.min(height * 2, 1000) + reuseItems: true + + onCurrentIndexChanged: { + if (keyboardNavigationActive) + ensureVisible(currentIndex); + } + onItemClicked: function(index, modelData) { appLauncher.launchApp(modelData); } @@ -393,20 +424,170 @@ PanelWindow { onKeyboardNavigationReset: { appLauncher.keyboardNavigationActive = false; } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AlwaysOn + } + + ScrollBar.horizontal: ScrollBar { + policy: ScrollBar.AlwaysOff + } + + delegate: Rectangle { + width: ListView.view.width + height: appList.itemHeight + radius: Theme.cornerRadiusLarge + color: ListView.isCurrentItem ? Theme.primaryPressed : mouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03) + border.color: ListView.isCurrentItem ? Theme.primarySelected : Theme.outlineMedium + border.width: ListView.isCurrentItem ? 2 : 1 + + Row { + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingL + + Item { + width: appList.iconSize + height: appList.iconSize + anchors.verticalCenter: parent.verticalCenter + + IconImage { + id: iconImg + + anchors.fill: parent + source: (model.icon) ? Quickshell.iconPath(model.icon, SettingsData.iconTheme === "System Default" ? "" : SettingsData.iconTheme) : "" + smooth: true + asynchronous: true + visible: status === Image.Ready + } + + Rectangle { + anchors.fill: parent + visible: !iconImg.visible + color: Theme.surfaceLight + radius: Theme.cornerRadiusLarge + border.width: 1 + border.color: Theme.primarySelected + + StyledText { + anchors.centerIn: parent + text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A" + font.pixelSize: appList.iconSize * 0.4 + color: Theme.primary + font.weight: Font.Bold + } + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - appList.iconSize - Theme.spacingL + spacing: Theme.spacingXS + + StyledText { + width: parent.width + text: model.name || "" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + elide: Text.ElideRight + } + + StyledText { + width: parent.width + text: model.comment || "Application" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + elide: Text.ElideRight + visible: appList.showDescription && model.comment && model.comment.length > 0 + } + } + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + z: 10 + onEntered: { + if (appList.hoverUpdatesSelection && !appList.keyboardNavigationActive) + appList.currentIndex = index; + + appList.itemHovered(index); + } + onPositionChanged: { + appList.keyboardNavigationReset(); + } + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + appList.itemClicked(index, model); + } else if (mouse.button === Qt.RightButton) { + var globalPos = mapToGlobal(mouse.x, mouse.y); + appList.itemRightClicked(index, model, globalPos.x, globalPos.y); + } + } + } + } } - DankGridView { + GoodScrollingGridView { id: appGrid + property int currentIndex: appLauncher.selectedIndex + property int columns: 4 + property bool adaptiveColumns: false + property int minCellWidth: 120 + property int maxCellWidth: 160 + property int cellPadding: 8 + property real iconSizeRatio: 0.6 + property int maxIconSize: 56 + property int minIconSize: 32 + property bool hoverUpdatesSelection: false + property bool keyboardNavigationActive: appLauncher.keyboardNavigationActive + property int baseCellWidth: adaptiveColumns ? Math.max(minCellWidth, Math.min(maxCellWidth, width / columns)) : (width - Theme.spacingS * 2) / columns + property int baseCellHeight: baseCellWidth + 20 + property int actualColumns: adaptiveColumns ? Math.floor(width / cellWidth) : columns + property int remainingSpace: width - (actualColumns * cellWidth) + + signal keyboardNavigationReset() + signal itemClicked(int index, var modelData) + signal itemHovered(int index) + signal itemRightClicked(int index, var modelData, real mouseX, real mouseY) + + function ensureVisible(index) { + if (index < 0 || index >= count) + return; + + var itemY = Math.floor(index / actualColumns) * cellHeight; + var itemBottom = itemY + cellHeight; + if (itemY < contentY) + contentY = itemY; + else if (itemBottom > contentY + height) + contentY = itemBottom - height; + } + anchors.fill: parent anchors.margins: Theme.spacingS visible: appLauncher.viewMode === "grid" model: appLauncher.model - columns: 4 - adaptiveColumns: false - currentIndex: appLauncher.selectedIndex - hoverUpdatesSelection: false - keyboardNavigationActive: appLauncher.keyboardNavigationActive + clip: true + cellWidth: baseCellWidth + cellHeight: baseCellHeight + leftMargin: Math.max(Theme.spacingS, remainingSpace / 2) + rightMargin: leftMargin + focus: true + interactive: true + cacheBuffer: Math.min(height * 2, 1000) + reuseItems: true + + onCurrentIndexChanged: { + if (keyboardNavigationActive) + ensureVisible(currentIndex); + } + onItemClicked: function(index, modelData) { appLauncher.launchApp(modelData); } @@ -419,6 +600,103 @@ PanelWindow { onKeyboardNavigationReset: { appLauncher.keyboardNavigationActive = false; } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ScrollBar.horizontal: ScrollBar { + policy: ScrollBar.AlwaysOff + } + + delegate: Rectangle { + width: appGrid.cellWidth - appGrid.cellPadding + height: appGrid.cellHeight - appGrid.cellPadding + radius: Theme.cornerRadiusLarge + color: appGrid.currentIndex === index ? Theme.primaryPressed : mouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03) + border.color: appGrid.currentIndex === index ? Theme.primarySelected : Theme.outlineMedium + border.width: appGrid.currentIndex === index ? 2 : 1 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingS + + Item { + property int iconSize: Math.min(appGrid.maxIconSize, Math.max(appGrid.minIconSize, appGrid.cellWidth * appGrid.iconSizeRatio)) + + width: iconSize + height: iconSize + anchors.horizontalCenter: parent.horizontalCenter + + IconImage { + id: iconImg + + anchors.fill: parent + source: (model.icon) ? Quickshell.iconPath(model.icon, SettingsData.iconTheme === "System Default" ? "" : SettingsData.iconTheme) : "" + smooth: true + asynchronous: true + visible: status === Image.Ready + } + + Rectangle { + anchors.fill: parent + visible: !iconImg.visible + color: Theme.surfaceLight + radius: Theme.cornerRadiusLarge + border.width: 1 + border.color: Theme.primarySelected + + StyledText { + anchors.centerIn: parent + text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A" + font.pixelSize: Math.min(28, parent.width * 0.5) + color: Theme.primary + font.weight: Font.Bold + } + } + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + width: appGrid.cellWidth - 12 + text: model.name || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Medium + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + maximumLineCount: 2 + wrapMode: Text.WordWrap + } + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + z: 10 + onEntered: { + if (appGrid.hoverUpdatesSelection && !appGrid.keyboardNavigationActive) + appGrid.currentIndex = index; + + appGrid.itemHovered(index); + } + onPositionChanged: { + appGrid.keyboardNavigationReset(); + } + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + appGrid.itemClicked(index, model); + } else if (mouse.button === Qt.RightButton) { + var globalPos = mapToGlobal(mouse.x, mouse.y); + appGrid.itemRightClicked(index, model, globalPos.x, globalPos.y); + } + } + } + } } } diff --git a/Widgets/DankGridView.qml b/Widgets/DankGridView.qml deleted file mode 100644 index ee032a65..00000000 --- a/Widgets/DankGridView.qml +++ /dev/null @@ -1,220 +0,0 @@ -import QtQuick -import QtQuick.Controls -import Quickshell -import Quickshell.Widgets -import qs.Common - -GridView { - id: gridView - - property int currentIndex: 0 - property int columns: 4 - property bool adaptiveColumns: false - property int minCellWidth: 120 - property int maxCellWidth: 160 - property int cellPadding: 8 - property real iconSizeRatio: 0.6 - property int maxIconSize: 56 - property int minIconSize: 32 - property bool hoverUpdatesSelection: true - property bool keyboardNavigationActive: false - property int baseCellWidth: adaptiveColumns ? Math.max(minCellWidth, Math.min(maxCellWidth, width / columns)) : (width - Theme.spacingS * 2) / columns - property int baseCellHeight: baseCellWidth + 20 - property int actualColumns: adaptiveColumns ? Math.floor(width / cellWidth) : columns - property int remainingSpace: width - (actualColumns * cellWidth) - - signal keyboardNavigationReset() - signal itemClicked(int index, var modelData) - signal itemHovered(int index) - signal itemRightClicked(int index, var modelData, real mouseX, real mouseY) - - function ensureVisible(index) { - if (index < 0 || index >= gridView.count) - return ; - - var itemY = Math.floor(index / gridView.actualColumns) * gridView.cellHeight; - var itemBottom = itemY + gridView.cellHeight; - if (itemY < gridView.contentY) - gridView.contentY = itemY; - else if (itemBottom > gridView.contentY + gridView.height) - gridView.contentY = itemBottom - gridView.height; - } - - onCurrentIndexChanged: { - if (keyboardNavigationActive) - ensureVisible(currentIndex); - - } - clip: true - anchors.margins: Theme.spacingS - cellWidth: baseCellWidth - cellHeight: baseCellHeight - leftMargin: Math.max(Theme.spacingS, remainingSpace / 2) - rightMargin: leftMargin - focus: true - interactive: true - - // Qt 6.9+ scrolling: flickDeceleration/maximumFlickVelocity only affect touch now - flickDeceleration: 1500 - maximumFlickVelocity: 2000 - boundsBehavior: Flickable.StopAtBounds - boundsMovement: Flickable.FollowBoundsBehavior - pressDelay: 0 - flickableDirection: Flickable.VerticalFlick - - // Performance optimizations - cacheBuffer: Math.min(height * 2, 1000) - reuseItems: true - - // Custom wheel handler for Qt 6.9+ responsive mouse wheel scrolling - WheelHandler { - id: wheelHandler - acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad - - // Tunable parameters for responsive scrolling - property real mouseWheelSpeed: 20 // Higher = faster mouse wheel - property real touchpadSpeed: 1.8 // Touchpad sensitivity - property real momentumRetention: 0.92 - property real lastWheelTime: 0 - property real momentum: 0 - - onWheel: (event) => { - let currentTime = Date.now() - let timeDelta = currentTime - lastWheelTime - lastWheelTime = currentTime - - // Calculate scroll delta based on input type - let delta = 0 - if (event.pixelDelta.y !== 0) { - // Touchpad with pixel precision - delta = event.pixelDelta.y * touchpadSpeed - } else { - // Mouse wheel - larger steps for faster scrolling - delta = event.angleDelta.y / 120 * cellHeight * 2 // 2 cells per wheel step - } - - // Apply momentum for touchpad (smooth continuous scrolling) - if (event.pixelDelta.y !== 0 && timeDelta < 50) { - momentum = momentum * momentumRetention + delta * 0.15 - delta += momentum - } else { - momentum = 0 - } - - // Apply scrolling with proper bounds checking - let newY = contentY - delta - newY = Math.max(0, Math.min( - contentHeight - height, newY)) - - // Cancel any conflicting flicks and apply new position - if (flicking) { - cancelFlick() - } - - contentY = newY - event.accepted = true - } - } - - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded - } - - ScrollBar.horizontal: ScrollBar { - policy: ScrollBar.AlwaysOff - } - - delegate: Rectangle { - width: gridView.cellWidth - cellPadding - height: gridView.cellHeight - cellPadding - radius: Theme.cornerRadiusLarge - color: currentIndex === index ? Theme.primaryPressed : mouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03) - border.color: currentIndex === index ? Theme.primarySelected : Theme.outlineMedium - border.width: currentIndex === index ? 2 : 1 - - Column { - anchors.centerIn: parent - spacing: Theme.spacingS - - Item { - property int iconSize: Math.min(maxIconSize, Math.max(minIconSize, gridView.cellWidth * iconSizeRatio)) - - width: iconSize - height: iconSize - anchors.horizontalCenter: parent.horizontalCenter - - IconImage { - id: iconImg - - anchors.fill: parent - source: (model.icon) ? Quickshell.iconPath(model.icon, SettingsData.iconTheme === "System Default" ? "" : SettingsData.iconTheme) : "" - smooth: true - asynchronous: true - visible: status === Image.Ready - } - - Rectangle { - anchors.fill: parent - visible: !iconImg.visible - color: Theme.surfaceLight - radius: Theme.cornerRadiusLarge - border.width: 1 - border.color: Theme.primarySelected - - StyledText { - anchors.centerIn: parent - text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A" - font.pixelSize: Math.min(28, parent.width * 0.5) - color: Theme.primary - font.weight: Font.Bold - } - - } - - } - - StyledText { - anchors.horizontalCenter: parent.horizontalCenter - width: gridView.cellWidth - 12 - text: model.name || "" - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - font.weight: Font.Medium - elide: Text.ElideRight - horizontalAlignment: Text.AlignHCenter - maximumLineCount: 2 - wrapMode: Text.WordWrap - } - - } - - MouseArea { - id: mouseArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton - z: 10 - onEntered: { - if (hoverUpdatesSelection && !keyboardNavigationActive) - currentIndex = index; - - itemHovered(index); - } - onPositionChanged: { - keyboardNavigationReset(); - } - onClicked: (mouse) => { - if (mouse.button === Qt.LeftButton) { - itemClicked(index, model); - } else if (mouse.button === Qt.RightButton) { - var globalPos = mapToGlobal(mouse.x, mouse.y); - itemRightClicked(index, model, globalPos.x, globalPos.y); - } - } - } - - } - -} diff --git a/Widgets/DankListView.qml b/Widgets/DankListView.qml deleted file mode 100644 index c18e5712..00000000 --- a/Widgets/DankListView.qml +++ /dev/null @@ -1,219 +0,0 @@ -import QtQuick -import QtQuick.Controls -import Quickshell -import Quickshell.Widgets -import qs.Common - -ListView { - id: listView - - property int itemHeight: 72 - property int iconSize: 56 - property bool showDescription: true - property int itemSpacing: Theme.spacingS - property bool hoverUpdatesSelection: true - property bool keyboardNavigationActive: false - - signal keyboardNavigationReset() - signal itemClicked(int index, var modelData) - signal itemHovered(int index) - signal itemRightClicked(int index, var modelData, real mouseX, real mouseY) - - function ensureVisible(index) { - if (index < 0 || index >= count) - return ; - - var itemY = index * (itemHeight + itemSpacing); - var itemBottom = itemY + itemHeight; - if (itemY < contentY) - contentY = itemY; - else if (itemBottom > contentY + height) - contentY = itemBottom - height; - } - - onCurrentIndexChanged: { - if (keyboardNavigationActive) - ensureVisible(currentIndex); - } - - clip: true - anchors.margins: itemSpacing - spacing: itemSpacing - focus: true - interactive: true - - // Qt 6.9+ scrolling: flickDeceleration/maximumFlickVelocity only affect touch now - flickDeceleration: 1500 - maximumFlickVelocity: 2000 - boundsBehavior: Flickable.StopAtBounds - boundsMovement: Flickable.FollowBoundsBehavior - pressDelay: 0 - flickableDirection: Flickable.VerticalFlick - - // Performance optimizations - cacheBuffer: Math.min(height * 2, 1000) - reuseItems: true - - // Custom wheel handler for Qt 6.9+ responsive mouse wheel scrolling - WheelHandler { - id: wheelHandler - acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad - - // Tunable parameters for responsive scrolling - property real mouseWheelSpeed: 20 // Higher = faster mouse wheel - property real touchpadSpeed: 1.8 // Touchpad sensitivity - property real momentumRetention: 0.92 - property real lastWheelTime: 0 - property real momentum: 0 - - onWheel: (event) => { - let currentTime = Date.now() - let timeDelta = currentTime - lastWheelTime - lastWheelTime = currentTime - - // Calculate scroll delta based on input type - let delta = 0 - if (event.pixelDelta.y !== 0) { - // Touchpad with pixel precision - delta = event.pixelDelta.y * touchpadSpeed - } else { - // Mouse wheel - larger steps for faster scrolling - delta = event.angleDelta.y / 120 * itemHeight * 2.5 // 2.5 items per wheel step - } - - // Apply momentum for touchpad (smooth continuous scrolling) - if (event.pixelDelta.y !== 0 && timeDelta < 50) { - momentum = momentum * momentumRetention + delta * 0.15 - delta += momentum - } else { - momentum = 0 - } - - // Apply scrolling with proper bounds checking - let newY = listView.contentY - delta - newY = Math.max(0, Math.min( - listView.contentHeight - listView.height, newY)) - - // Cancel any conflicting flicks and apply new position - if (listView.flicking) { - listView.cancelFlick() - } - - listView.contentY = newY - event.accepted = true - } - } - - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AlwaysOn - } - - ScrollBar.horizontal: ScrollBar { - policy: ScrollBar.AlwaysOff - } - - delegate: Rectangle { - width: ListView.view.width - height: itemHeight - radius: Theme.cornerRadiusLarge - color: ListView.isCurrentItem ? Theme.primaryPressed : mouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03) - border.color: ListView.isCurrentItem ? Theme.primarySelected : Theme.outlineMedium - border.width: ListView.isCurrentItem ? 2 : 1 - - Row { - anchors.fill: parent - anchors.margins: Theme.spacingM - spacing: Theme.spacingL - - Item { - width: iconSize - height: iconSize - anchors.verticalCenter: parent.verticalCenter - - IconImage { - id: iconImg - - anchors.fill: parent - source: (model.icon) ? Quickshell.iconPath(model.icon, SettingsData.iconTheme === "System Default" ? "" : SettingsData.iconTheme) : "" - smooth: true - asynchronous: true - visible: status === Image.Ready - } - - Rectangle { - anchors.fill: parent - visible: !iconImg.visible - color: Theme.surfaceLight - radius: Theme.cornerRadiusLarge - border.width: 1 - border.color: Theme.primarySelected - - StyledText { - anchors.centerIn: parent - text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A" - font.pixelSize: iconSize * 0.4 - color: Theme.primary - font.weight: Font.Bold - } - - } - - } - - Column { - anchors.verticalCenter: parent.verticalCenter - width: parent.width - iconSize - Theme.spacingL - spacing: Theme.spacingXS - - StyledText { - width: parent.width - text: model.name || "" - font.pixelSize: Theme.fontSizeLarge - color: Theme.surfaceText - font.weight: Font.Medium - elide: Text.ElideRight - } - - StyledText { - width: parent.width - text: model.comment || "Application" - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceVariantText - elide: Text.ElideRight - visible: showDescription && model.comment && model.comment.length > 0 - } - - } - - } - - MouseArea { - id: mouseArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton - z: 10 - onEntered: { - if (hoverUpdatesSelection && !keyboardNavigationActive) - currentIndex = index; - - itemHovered(index); - } - onPositionChanged: { - keyboardNavigationReset(); - } - onClicked: (mouse) => { - if (mouse.button === Qt.LeftButton) { - itemClicked(index, model); - } else if (mouse.button === Qt.RightButton) { - var globalPos = mapToGlobal(mouse.x, mouse.y); - itemRightClicked(index, model, globalPos.x, globalPos.y); - } - } - } - - } - -} diff --git a/Widgets/GoodScrollingGridView.qml b/Widgets/GoodScrollingGridView.qml new file mode 100644 index 00000000..5ec34434 --- /dev/null +++ b/Widgets/GoodScrollingGridView.qml @@ -0,0 +1,151 @@ +import QtQuick +import QtQuick.Controls + +GridView { + id: gridView + + // Kinetic scrolling momentum properties + property real momentumVelocity: 0 + property bool isMomentumActive: false + property real friction: 0.95 + property real minMomentumVelocity: 50 + property real maxMomentumVelocity: 2500 + + // Qt 6.9+ scrolling: flickDeceleration/maximumFlickVelocity only affect touch now + flickDeceleration: 1500 + maximumFlickVelocity: 2000 + boundsBehavior: Flickable.StopAtBounds + boundsMovement: Flickable.FollowBoundsBehavior + pressDelay: 0 + flickableDirection: Flickable.VerticalFlick + + // Custom wheel handler for Qt 6.9+ responsive mouse wheel scrolling + WheelHandler { + id: wheelHandler + + // Tunable parameters for responsive scrolling + property real mouseWheelSpeed: 20 + // Higher = faster mouse wheel + property real touchpadSpeed: 1.8 + // Touchpad sensitivity + property real momentumRetention: 0.92 + property real lastWheelTime: 0 + property real momentum: 0 + property var velocitySamples: [] + + function startMomentum() { + isMomentumActive = true; + momentumTimer.start(); + } + + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: (event) => { + // Stop any existing momentum + momentumTimer.stop(); + isMomentumActive = false; + let currentTime = Date.now(); + let timeDelta = currentTime - lastWheelTime; + lastWheelTime = currentTime; + // Calculate scroll delta based on input type + let delta = 0; + if (event.pixelDelta.y !== 0) + // Touchpad with pixel precision + delta = event.pixelDelta.y * touchpadSpeed; + else + // Mouse wheel - moderate steps for comfortable scrolling + delta = event.angleDelta.y / 120 * cellHeight * 1.2; + // Track velocity for momentum + velocitySamples.push({ + "delta": delta, + "time": currentTime + }); + velocitySamples = velocitySamples.filter((s) => { + return currentTime - s.time < 100; + }); + // Calculate momentum velocity from samples + if (velocitySamples.length > 1) { + let totalDelta = velocitySamples.reduce((sum, s) => { + return sum + s.delta; + }, 0); + let timeSpan = currentTime - velocitySamples[0].time; + if (timeSpan > 0) + momentumVelocity = Math.max(-maxMomentumVelocity, Math.min(maxMomentumVelocity, totalDelta / timeSpan * 1000)); + + } + // Apply momentum for touchpad (smooth continuous scrolling) + if (event.pixelDelta.y !== 0 && timeDelta < 50) { + momentum = momentum * momentumRetention + delta * 0.15; + delta += momentum; + } else { + momentum = 0; + } + // Apply scrolling with proper bounds checking + let newY = contentY - delta; + newY = Math.max(0, Math.min(contentHeight - height, newY)); + // Cancel any conflicting flicks and apply new position + if (flicking) + cancelFlick(); + + contentY = newY; + event.accepted = true; + } + onActiveChanged: { + if (!active && Math.abs(momentumVelocity) >= minMomentumVelocity) { + startMomentum(); + } else if (!active) { + velocitySamples = []; + momentumVelocity = 0; + } + } + } + + // Physics-based momentum timer for kinetic scrolling + Timer { + id: momentumTimer + + interval: 16 // ~60 FPS + repeat: true + onTriggered: { + // Apply velocity to position + let newY = contentY - momentumVelocity * 0.016; + let maxY = Math.max(0, contentHeight - height); + + // Stop momentum at boundaries instead of bouncing + if (newY < 0) { + contentY = 0; + stop(); + isMomentumActive = false; + momentumVelocity = 0; + return; + } else if (newY > maxY) { + contentY = maxY; + stop(); + isMomentumActive = false; + momentumVelocity = 0; + return; + } + + contentY = newY; + + // Apply friction + momentumVelocity *= friction; + + // Stop if velocity too low + if (Math.abs(momentumVelocity) < 5) { + stop(); + isMomentumActive = false; + momentumVelocity = 0; + } + } + } + + // Smooth return to bounds animation + NumberAnimation { + id: returnToBoundsAnimation + + target: gridView + property: "contentY" + duration: 300 + easing.type: Easing.OutQuad + } +} \ No newline at end of file diff --git a/Widgets/GoodScrollingListView.qml b/Widgets/GoodScrollingListView.qml new file mode 100644 index 00000000..e041ac58 --- /dev/null +++ b/Widgets/GoodScrollingListView.qml @@ -0,0 +1,151 @@ +import QtQuick +import QtQuick.Controls + +ListView { + id: listView + + // Kinetic scrolling momentum properties + property real momentumVelocity: 0 + property bool isMomentumActive: false + property real friction: 0.95 + property real minMomentumVelocity: 50 + property real maxMomentumVelocity: 2500 + + // Qt 6.9+ scrolling: flickDeceleration/maximumFlickVelocity only affect touch now + flickDeceleration: 1500 + maximumFlickVelocity: 2000 + boundsBehavior: Flickable.StopAtBounds + boundsMovement: Flickable.FollowBoundsBehavior + pressDelay: 0 + flickableDirection: Flickable.VerticalFlick + + // Custom wheel handler for Qt 6.9+ responsive mouse wheel scrolling + WheelHandler { + id: wheelHandler + + // Tunable parameters for responsive scrolling + property real mouseWheelSpeed: 20 + // Higher = faster mouse wheel + property real touchpadSpeed: 1.8 + // Touchpad sensitivity + property real momentumRetention: 0.92 + property real lastWheelTime: 0 + property real momentum: 0 + property var velocitySamples: [] + + function startMomentum() { + isMomentumActive = true; + momentumTimer.start(); + } + + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: (event) => { + // Stop any existing momentum + momentumTimer.stop(); + isMomentumActive = false; + let currentTime = Date.now(); + let timeDelta = currentTime - lastWheelTime; + lastWheelTime = currentTime; + // Calculate scroll delta based on input type + let delta = 0; + if (event.pixelDelta.y !== 0) + // Touchpad with pixel precision + delta = event.pixelDelta.y * touchpadSpeed; + else + // Mouse wheel - moderate steps for comfortable scrolling + delta = event.angleDelta.y / 120 * 72 * 1.2; // Using default item height + // Track velocity for momentum + velocitySamples.push({ + "delta": delta, + "time": currentTime + }); + velocitySamples = velocitySamples.filter((s) => { + return currentTime - s.time < 100; + }); + // Calculate momentum velocity from samples + if (velocitySamples.length > 1) { + let totalDelta = velocitySamples.reduce((sum, s) => { + return sum + s.delta; + }, 0); + let timeSpan = currentTime - velocitySamples[0].time; + if (timeSpan > 0) + momentumVelocity = Math.max(-maxMomentumVelocity, Math.min(maxMomentumVelocity, totalDelta / timeSpan * 1000)); + + } + // Apply momentum for touchpad (smooth continuous scrolling) + if (event.pixelDelta.y !== 0 && timeDelta < 50) { + momentum = momentum * momentumRetention + delta * 0.15; + delta += momentum; + } else { + momentum = 0; + } + // Apply scrolling with proper bounds checking + let newY = listView.contentY - delta; + newY = Math.max(0, Math.min(listView.contentHeight - listView.height, newY)); + // Cancel any conflicting flicks and apply new position + if (listView.flicking) + listView.cancelFlick(); + + listView.contentY = newY; + event.accepted = true; + } + onActiveChanged: { + if (!active && Math.abs(momentumVelocity) >= minMomentumVelocity) { + startMomentum(); + } else if (!active) { + velocitySamples = []; + momentumVelocity = 0; + } + } + } + + // Physics-based momentum timer for kinetic scrolling + Timer { + id: momentumTimer + + interval: 16 // ~60 FPS + repeat: true + onTriggered: { + // Apply velocity to position + let newY = contentY - momentumVelocity * 0.016; + let maxY = Math.max(0, contentHeight - height); + + // Stop momentum at boundaries instead of bouncing + if (newY < 0) { + contentY = 0; + stop(); + isMomentumActive = false; + momentumVelocity = 0; + return; + } else if (newY > maxY) { + contentY = maxY; + stop(); + isMomentumActive = false; + momentumVelocity = 0; + return; + } + + contentY = newY; + + // Apply friction + momentumVelocity *= friction; + + // Stop if velocity too low + if (Math.abs(momentumVelocity) < 5) { + stop(); + isMomentumActive = false; + momentumVelocity = 0; + } + } + } + + // Smooth return to bounds animation + NumberAnimation { + id: returnToBoundsAnimation + + target: listView + property: "contentY" + duration: 300 + easing.type: Easing.OutQuad + } +} \ No newline at end of file