diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 78d50668..12b950b5 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -45,6 +45,13 @@ Item { property bool useTransparentForeground: false property bool transparent: false property bool centerSectionHovered: false + // One bar surface exists per monitor and each reports into this count, so a + // pointer crossing from one monitor's bar to another's stays counted however + // the enter and leave interleave. A single shared bool would be left false by + // whichever event landed last. + property int barHoverCount: 0 + // True while the pointer is over any bar, widgets included. + readonly property bool barHovered: barHoverCount > 0 property bool centerSectionRevealHeld: false property bool centerHoverRevealSuppressed: false property int barConfigSerial: 0 @@ -559,6 +566,9 @@ Item { Component.onCompleted: applyBarConfig() + // Revealing the indicators widens their section, which can slide a neighbour + // under a stationary pointer. Collapsing on that un-hover would move it back + // out and re-open the peek, so hold until the pointer leaves the bar. function setCenterSectionHovered(hovered) { centerSectionHovered = hovered if (hovered) { @@ -569,10 +579,18 @@ Item { } } + function setBarHovered(hovered) { + barHoverCount = Math.max(0, barHoverCount + (hovered ? 1 : -1)) + if (barHoverCount === 0) centerSectionRevealTimer.restart() + } + Timer { id: centerSectionRevealTimer interval: 120 - onTriggered: root.centerSectionRevealHeld = root.centerSectionHovered + // Collapse only. Opening the peek is the center section's own gesture, done + // in setCenterSectionHovered, so a timer left pending by a pointer that dipped + // off the bar and came back cannot reveal indicators it never pointed at. + onTriggered: if (!root.centerSectionHovered && !root.barHovered) root.centerSectionRevealHeld = false } function run(command) { @@ -993,6 +1011,16 @@ Item { Loader { anchors.fill: parent sourceComponent: root.vertical ? verticalBar : horizontalBar + + // A child of the loader, not a sibling of the sections: an ancestor stays + // hovered while the pointer is over a widget, where a sibling would lose + // hover to the section the pointer entered. + HoverHandler { + onHoveredChanged: root.setBarHovered(hovered) + // Unplugging a monitor destroys its bar without a leave event, which + // would strand this surface's tally and hold the peek open for good. + Component.onDestruction: if (hovered) root.setBarHovered(false) + } } PopupWindow { diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 0dede450..281d6337 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -61,6 +61,67 @@ assertEqual(bar.pickDrawnSlot([placeholder]), placeholder, 'bar falls back to th assertEqual(bar.pickDrawnSlot([]), null, 'bar reports no slot when there are none') assertEqual(bar.pickDrawnSlot(null), null, 'bar tolerates a missing slot list') +// Revealing the indicators can slide a neighbouring widget under a stationary +// pointer; collapsing the peek on that un-hover re-opens it and stutters the +// bar, so the peek stays held while the pointer is anywhere on the bar. +const revealTimer = barSource.slice(barSource.indexOf('id: centerSectionRevealTimer')) +const revealTimerBody = revealTimer.slice(0, revealTimer.indexOf('\n }')) +assert( + /!root\.centerSectionHovered && !root\.barHovered/.test(revealTimerBody), + 'the indicator peek stays held while the pointer is anywhere on the bar' +) + +// The timer runs on a delay, so it can fire for a pointer that has already come +// back. Letting it assign the held state outright would then reveal indicators +// from bar hover alone; it may only close what the center section opened. +assert( + !/centerSectionRevealHeld = (?!false)/.test(revealTimerBody), + 'the delayed collapse can only close the peek, never open it' +) + +// The whole-bar hover has to come from an ancestor of the sections. A sibling +// loses hover to whichever section the pointer moved onto, which is the very +// signal the peek must not collapse on. +const barLoader = barSource.slice(barSource.indexOf('sourceComponent: root.vertical ? verticalBar : horizontalBar')) +const barLoaderBody = barLoader.slice(0, barLoader.indexOf('\n }')) +assert( + /setBarHovered\(hovered\)/.test(barLoaderBody), + 'the whole-bar hover handler is a child of the bar loader, above both orientations' +) + +// Unplugging a monitor tears its bar down mid-hover with no leave event, which +// would leave that surface counted forever and the peek stuck open. +assert( + /Component\.onDestruction: if \(hovered\) root\.setBarHovered\(false\)/.test(barLoaderBody), + 'a bar torn down while hovered gives its hover back' +) + +// The helper has to record the state it is handed and re-run the collapse once +// the pointer leaves. It counts rather than assigns because every monitor's bar +// reports here: a slide from one bar to the next can deliver the enter before +// the leave, and a shared bool would read as un-hovered under a live pointer. +const setBarHovered = barSource.slice(barSource.indexOf('function setBarHovered')) +const setBarHoveredBody = setBarHovered.slice(0, setBarHovered.indexOf('\n }')) +assert( + /barHoverCount = Math\.max\(0, barHoverCount \+ \(hovered \? 1 : -1\)\)/.test(setBarHoveredBody), + 'each bar surface adds to a hover tally instead of overwriting a shared flag' +) +assert( + /if \(barHoverCount === 0\) centerSectionRevealTimer\.restart\(\)/.test(setBarHoveredBody), + 'the peek collapse re-runs once the pointer has left the last bar' +) + +// Opening the peek stays the center section's own gesture: pointing straight at +// a widget reveals nothing. Checking that only inside setBarHovered proves +// nothing, since the shared reveal timer is the path a bar hover leaks through. +const opensPeek = barSource.split('\n').filter(line => /centerSectionRevealHeld = true/.test(line)) +assertEqual(opensPeek.length, 1, 'exactly one line in the bar opens the indicator peek') +const setCenterSectionHovered = barSource.slice(barSource.indexOf('function setCenterSectionHovered')) +assert( + setCenterSectionHovered.slice(0, setCenterSectionHovered.indexOf('\n }')).includes(opensPeek[0].trim()), + 'hovering the bar never opens the peek on its own' +) + // A bar surface is built per monitor, so a panel hotkey has one live copy of // the widget per screen to choose between. const internal = { moduleName: 'omarchy.audio', visible: true, width: 28, height: 81 }