From 1960d185720e1de137f0b3619c91904f16df8fd9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 31 Jul 2026 21:38:47 -0500 Subject: [PATCH] Make menu overflow visible with a half-row peek and edge fades Menus used to cut off clean at ten rows, so anything below the fold was undiscoverable. Now the list sizes to what fits within 60% of the screen and always ends mid-row when items overflow, with scroll-position-driven fades at both edges. Keyboard navigation keeps the next hidden row peeking past the cursor so the fold affordance travels with the selection. Co-Authored-By: Claude Fable 5 --- shell/plugins/menu/Menu.qml | 112 +++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 9 deletions(-) diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 5e80c4d1..e7b7e4d5 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -101,6 +101,9 @@ Item { property int contentSpacing: Style.spacing.md property int baseRowHeight: Math.max(Style.space(50), Style.font.body + Style.spacing.rowPaddingX * 2) property int detailRowHeight: Math.max(Style.space(58), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2) + // How much of the first hidden row stays visible at the fold — enough to + // read as a cut-off row rather than a bottom border. + property int rowPeek: Math.round(baseRowHeight * 0.55) property int rowSpacing: Style.spacing.xs property int dividerHeight: Style.space(17) property bool searchDivider: false @@ -142,36 +145,69 @@ Item { return root.filterText && detail ? root.detailRowHeight : root.baseRowHeight } + // Height the card can devote to rows before running off the screen — or + // past the frozen top edge once a search has pinned the card in place. + // Uses panel.cardTop rather than effectiveCardTop: the centered top is + // derived from the card height, which this value feeds. + function availableRowsHeight() { + var top = panel.cardTop >= 0 ? panel.cardTop : Style.gapsOut + var available = panel.height - top - Style.gapsOut - root.contentMargin * 2 - root.headerHeight - root.contentSpacing + // A card that swallows the whole screen reads as a page, not a menu. + return Math.min(available, Math.round(panel.height * 0.6)) + } + + // When every row fits, the list gets its full height. When they don't, + // the card must end mid-row: a clipped row is what tells the eye there is + // more below the fold, so never come out even on a row boundary. + function foldedListHeight(totals, available) { + var count = totals.length + if (count === 0) return root.baseRowHeight + if (totals[count - 1] <= available) return totals[count - 1] + + var peek = root.rowPeek + var full = 0 + while (full < count && totals[full] <= available) full++ + while (full > 1 && totals[full - 1] + root.rowSpacing + peek > available) full-- + if (full < 1) return Math.max(available, root.baseRowHeight) + + return totals[full - 1] + root.rowSpacing + peek + } + function rowListHeight(_serial, _count, _filter, _divider) { if (displayModel.count === 0) return root.baseRowHeight - var count = Math.min(displayModel.count, 10) + var totals = [] var total = 0 var previousSection = "" - for (var i = 0; i < count; i++) { + for (var i = 0; i < displayModel.count; i++) { var row = displayModel.get(i) if (i > 0) total += root.rowSpacing if (row.section === "drilldown" && previousSection !== "drilldown") total += root.dividerHeight total += root.rowHeightForDetail(row.detail) previousSection = row.section + totals.push(total) } - return total + return foldedListHeight(totals, availableRowsHeight()) } function dmenuRowListHeight(_serial, _count, _filter) { if (root.mode === "input") return 0 if (displayModel.count === 0) return root.baseRowHeight - var count = Math.min(displayModel.count, 10) + var available = availableRowsHeight() + if (root.dmenuMaxHeight > 0) available = Math.min(available, Style.space(root.dmenuMaxHeight)) + + var totals = [] var total = 0 - for (var i = 0; i < count; i++) { + for (var i = 0; i < displayModel.count; i++) { if (i > 0) total += root.rowSpacing total += root.baseRowHeight + totals.push(total) } - return root.dmenuMaxHeight > 0 ? Math.min(total, Style.space(root.dmenuMaxHeight)) : total + return foldedListHeight(totals, available) } function item(id) { @@ -510,7 +546,7 @@ Item { else if (selectedIndex < 0) selectedIndex = 0 Qt.callLater(function() { - if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) + if (displayModel.count > 0) root.revealCursor() }) } @@ -591,10 +627,32 @@ Item { else if (selectedIndex < 0) selectedIndex = 0 Qt.callLater(function() { - if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) + if (displayModel.count > 0) root.revealCursor() }) } + // Contain alone parks the cursor row flush with the viewport edge, hiding + // the neighbor entirely and losing the fold affordance. Keep the next + // hidden row peeking past the cursor in the direction of travel. + function revealCursor() { + if (displayModel.count === 0) return + resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) + + var item = resultList.itemAtIndex(root.selectedIndex) + if (!item) return + + var reach = root.rowPeek + root.rowSpacing + if (root.selectedIndex < displayModel.count - 1) { + var maxY = Math.max(resultList.originY, resultList.originY + resultList.contentHeight - resultList.height) + var overhang = item.y + item.height + reach - (resultList.contentY + resultList.height) + if (overhang > 0) resultList.contentY = Math.min(resultList.contentY + overhang, maxY) + } + if (root.selectedIndex > 0) { + var underhang = resultList.contentY - (item.y - reach) + if (underhang > 0) resultList.contentY = Math.max(resultList.contentY - underhang, resultList.originY) + } + } + function select(delta) { if (displayModel.count === 0) return @@ -605,7 +663,7 @@ Item { } else { selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count } - resultList.positionViewAtIndex(selectedIndex, ListView.Contain) + revealCursor() } function setFilter(nextFilter) { @@ -1266,6 +1324,42 @@ Item { } } + // Scroll scrims. The clipped row already marks the fold at rest; + // these keep both edges honest once the list has been scrolled, + // when content hides above the card top as well as below. Strength + // tracks the distance still hidden past each edge rather than + // animating on a clock, so a programmatic jump — wrapping from the + // last row back to the first — lands with the fade already applied. + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: Math.min(Style.space(28), parent.height / 2) + visible: opacity > 0 + opacity: resultList.contentHeight > resultList.height + ? Math.max(0, Math.min(1, (resultList.contentY - resultList.originY) / height)) + : 0 + gradient: Gradient { + GradientStop { position: 0; color: root.background } + GradientStop { position: 1; color: Util.alpha(root.background, 0) } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: Math.min(Style.space(28), parent.height / 2) + visible: opacity > 0 + opacity: resultList.contentHeight > resultList.height + ? Math.max(0, Math.min(1, (resultList.originY + resultList.contentHeight - resultList.height - resultList.contentY) / height)) + : 0 + gradient: Gradient { + GradientStop { position: 0; color: Util.alpha(root.background, 0) } + GradientStop { position: 1; color: root.background } + } + } + Column { anchors.centerIn: parent spacing: Style.space(8)