From be71149500bc3f49bdf9282561cafd724cbbf0eb Mon Sep 17 00:00:00 2001 From: Carlos Armando Date: Wed, 22 Jul 2026 18:38:49 -0700 Subject: [PATCH 1/2] fix(shell): scroll long tray menus --- shell/plugins/bar/widgets/Tray.qml | 210 +++++++++++++++-------------- test/shell.d/tray-test.sh | 5 + 2 files changed, 117 insertions(+), 98 deletions(-) diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index 0355c93e..29487fc2 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -1,5 +1,6 @@ import Quickshell import QtQuick +import QtQuick.Controls import QtQuick.Effects import Quickshell.Services.SystemTray import qs.Commons @@ -446,116 +447,129 @@ BarWidget { contentWidth: trayMenuPopup.fittedContentWidth(Style.space(232)) contentHeight: trayMenuPopup.fittedContentHeight(trayMenuColumn.implicitHeight, Style.space(420)) - Column { - id: trayMenuColumn + Flickable { + id: trayMenuFlick anchors.fill: parent - spacing: 0 + contentWidth: width + contentHeight: trayMenuColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height - Repeater { - model: trayMenuOpener.children + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - delegate: Item { - id: menuRow - required property var modelData - required property int index + Column { + id: trayMenuColumn + width: trayMenuFlick.width + spacing: 0 - readonly property string rowText: String(modelData.text || "") - readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : "" - readonly property bool rootTitleEntry: index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase() - readonly property bool leadingSeparator: modelData.isSeparator && index <= 1 - readonly property bool hiddenRow: rootTitleEntry || leadingSeparator + Repeater { + model: trayMenuOpener.children - visible: !hiddenRow - width: trayMenuColumn.width - implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30)) - opacity: modelData.enabled ? 1.0 : 0.45 + delegate: Item { + id: menuRow + required property var modelData + required property int index - Rectangle { - visible: menuRow.modelData.isSeparator - anchors.left: parent.left - anchors.leftMargin: Style.space(10) - anchors.right: parent.right - anchors.rightMargin: Style.space(10) - anchors.verticalCenter: parent.verticalCenter - height: 1 - color: Color.popups.border - opacity: 0.45 - } + readonly property string rowText: String(modelData.text || "") + readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : "" + readonly property bool rootTitleEntry: index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase() + readonly property bool leadingSeparator: modelData.isSeparator && index <= 1 + readonly property bool hiddenRow: rootTitleEntry || leadingSeparator - Rectangle { - visible: !menuRow.modelData.isSeparator - anchors.fill: parent - radius: Math.max(2, Style.cornerRadius) - color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent" - } + visible: !hiddenRow + width: trayMenuColumn.width + implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30)) + opacity: modelData.enabled ? 1.0 : 0.45 - Text { - visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - width: Style.space(22) - horizontalAlignment: Text.AlignHCenter - text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : "" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } + Rectangle { + visible: menuRow.modelData.isSeparator + anchors.left: parent.left + anchors.leftMargin: Style.space(10) + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: Color.popups.border + opacity: 0.45 + } - Image { - id: menuIcon - visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== "" - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: Style.space(24) - width: Style.space(16) - height: Style.space(16) - fillMode: Image.PreserveAspectFit - // Decode at physical pixels: IconImage uses the logical size, - // which leaves PNG icons upscaled and blurry on HiDPI displays. - sourceSize.width: width * Screen.devicePixelRatio - sourceSize.height: height * Screen.devicePixelRatio - source: menuRow.modelData.icon - } + Rectangle { + visible: !menuRow.modelData.isSeparator + anchors.fill: parent + radius: Math.max(2, Style.cornerRadius) + color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent" + } - Text { - visible: !menuRow.modelData.isSeparator - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28) - anchors.right: submenuGlyph.left - anchors.rightMargin: Style.space(8) - text: menuRow.rowText - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - elide: Text.ElideRight - } + Text { + visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } - Text { - id: submenuGlyph - visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - anchors.rightMargin: Style.space(10) - text: "\u203a" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } + Image { + id: menuIcon + visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== "" + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Style.space(24) + width: Style.space(16) + height: Style.space(16) + fillMode: Image.PreserveAspectFit + // Decode at physical pixels: IconImage uses the logical size, + // which leaves PNG icons upscaled and blurry on HiDPI displays. + sourceSize.width: width * Screen.devicePixelRatio + sourceSize.height: height * Screen.devicePixelRatio + source: menuRow.modelData.icon + } - MouseArea { - id: rowMouse - anchors.fill: parent - hoverEnabled: true - enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - if (menuRow.modelData.hasChildren) { - var point = menuRow.QsWindow.contentItem.mapFromItem(menuRow, menuRow.width, menuRow.height / 2) - menuRow.modelData.display(menuRow.QsWindow.window, point.x, point.y) - } else { - menuRow.modelData.triggered() - root.close() + Text { + visible: !menuRow.modelData.isSeparator + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28) + anchors.right: submenuGlyph.left + anchors.rightMargin: Style.space(8) + text: menuRow.rowText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + + Text { + id: submenuGlyph + visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + text: "\u203a" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (menuRow.modelData.hasChildren) { + var point = menuRow.QsWindow.contentItem.mapFromItem(menuRow, menuRow.width, menuRow.height / 2) + menuRow.modelData.display(menuRow.QsWindow.window, point.x, point.y) + } else { + menuRow.modelData.triggered() + root.close() + } } } } diff --git a/test/shell.d/tray-test.sh b/test/shell.d/tray-test.sh index d2b2a25e..661ed175 100644 --- a/test/shell.d/tray-test.sh +++ b/test/shell.d/tray-test.sh @@ -4,6 +4,11 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +if ! perl -0ne 'exit(/id:\s*trayMenuPopup.*?Flickable\s*\{.*?contentHeight:\s*trayMenuColumn\.implicitHeight.*?ScrollBar\.vertical:\s*ScrollBar/s ? 0 : 1)' "$ROOT/shell/plugins/bar/widgets/Tray.qml"; then + fail "tray menu keeps capped content scrollable" +fi +pass "tray menu keeps capped content scrollable" + run_node_test "tray model helpers" <<'JS' const tray = requireFromRoot('shell/plugins/bar/widgets/TrayModel.js') From 332a982a1daee3ee91ac936007200ba7d113f5c2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 24 Jul 2026 09:09:02 -0700 Subject: [PATCH 2/2] Drop source-grep regression assertion A regex over the QML text breaks on innocent reformatting and passes even when the scrolling behavior regresses. Co-Authored-By: Claude Fable 5 --- test/shell.d/tray-test.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/shell.d/tray-test.sh b/test/shell.d/tray-test.sh index 661ed175..d2b2a25e 100644 --- a/test/shell.d/tray-test.sh +++ b/test/shell.d/tray-test.sh @@ -4,11 +4,6 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -if ! perl -0ne 'exit(/id:\s*trayMenuPopup.*?Flickable\s*\{.*?contentHeight:\s*trayMenuColumn\.implicitHeight.*?ScrollBar\.vertical:\s*ScrollBar/s ? 0 : 1)' "$ROOT/shell/plugins/bar/widgets/Tray.qml"; then - fail "tray menu keeps capped content scrollable" -fi -pass "tray menu keeps capped content scrollable" - run_node_test "tray model helpers" <<'JS' const tray = requireFromRoot('shell/plugins/bar/widgets/TrayModel.js')