From 1e25bd0883e6921de7d2fe1e9a24d9736bd16ce5 Mon Sep 17 00:00:00 2001 From: moWerk Date: Fri, 3 Apr 2026 23:44:46 +0200 Subject: [PATCH 1/2] quickpanel: Replace long-press step scrubber with drag-based value control - Replace holdTimer and directionChangeTimer step increment pattern in QuickPanelToggle with pressAndHold-activated horizontal drag scrubber. Scrub activates only after confirmed hold, so ListView swipes are not intercepted before the hold threshold fires. - Add scrubWidth property to QuickPanelToggle. Callers set this to rootitem.width so the full panel width is the drag range regardless of which toggle position initiated the scrub. - Add snap-to-position mapping via mapToItem(rootitem) so the fill position and finger position correspond truthfully across all toggle positions on any sliding row page. - Add 15% fat-finger margin on each side of scrubWidth so min and max are reachable before the screen edge. - Add scrubbing and wasScrubbing properties. wasScrubbing survives into onClicked to suppress the spurious click Qt fires after a hold drag. onPressed clears it at the start of the next fresh interaction. - Add activeScrub and scrubMode to rootitem as single sources of truth driving all simultaneous panel fades and scrub display content. - Fade out fixedRow, slidingRow, valueMeter, valueMeterCaption, flashIcon and pageDots together on activeScrub via shared 200ms Behavior. - Add scrubCover pill with layer+OpacityMask clipping, dark background at 0.75 opacity matching active toggle visual weight, and proportional fill rect tracking the live brightness or volume value with 80ms animation. - Add scrubEventGuard property and guardTimer to keep a swallowing MouseArea on scrubCover enabled for 250ms after scrub ends, preventing stray Qt touch events from reaching toggles after preventStealing releases. - Add morphRect pill that animates between valueMeter geometry and scrubCover geometry on scrub start and end. Color and opacity animate from valueMeter fillColor at 0.3 alpha to scrubCover dark at 0.75, and reverse symmetrically on scrub end. scrubCover crossfades in over morphRect at 150ms into the expand animation. - Add scrubValueLabel centered in fixedRow area showing live brightness or volume percentage during scrub. Add scrubCaptionLabel at valueMeter position showing Brightness or Volume string. Both fade opposite to the panel elements they replace. - scrubValueLabel always anchors to the top position and scrubCaptionLabel to the bottom position regardless of batteryBottom layout state, keeping the live value visible above the finger during scrub. - Disable valueMeter value Behavior during activeScrub to prevent the 250ms animation from permanently lagging behind the live label. - Remove valueMeter state machine and SequentialAnimation transitions for brightness and volume display. ValueMeter now shows battery only and fades out during scrub. scrubCover and scrubValueLabel take over the role of indicating the scrubbed value. - Sound toggle plays confirmation sound on scrub end when rangeValue > 0, mirroring the click-to-unmute behaviour. - Scrubbing to any volume > 0 while muted clears preMuteLevel, unmuting implicitly via the scrub gesture. - Fix spurious valueMeter state being set on any finger-down by moving state activation from onPressed to onScrubbingChanged, eliminating the stale state bug when a swipe gesture stole the event before hold fired. - Fix cinema toggle onClicked branch inversion introduced by the checked API migration. - Fix brightness toggle minimum using rangeMin 10 to respect the system-enforced brightness floor from meta-smartwatch. --- src/qml/quickpanel/QuickPanel.qml | 422 ++++++++++++++++-------- src/qml/quickpanel/QuickPanelToggle.qml | 80 +++-- 2 files changed, 339 insertions(+), 163 deletions(-) diff --git a/src/qml/quickpanel/QuickPanel.qml b/src/qml/quickpanel/QuickPanel.qml index c464f185..754399ae 100644 --- a/src/qml/quickpanel/QuickPanel.qml +++ b/src/qml/quickpanel/QuickPanel.qml @@ -50,11 +50,117 @@ Item { property bool forbidRight: true property int toggleSize: Dims.l(28) + // Set true while a range toggle is scrubbing. Single source of truth that + // drives all simultaneous panel fades via Behavior on opacity bindings. + property bool activeScrub: false + + // scrubEventGuard stays true for guardTimer interval after scrub ends. + // The MouseArea on scrubCover uses this to swallow stray touch events that + // Qt may deliver to underlying toggles after preventStealing is released. + // This is the primary fix for spurious toggle-on-scrub-release — wasScrubbing + // in the toggle is a backup for the short-drag case where onClicked fires. + property bool scrubEventGuard: false + + Timer { + id: guardTimer + interval: 250 + onTriggered: scrubEventGuard = false + } + + // Tracks which toggle is scrubbing so scrubFill and scrubCaptionLabel + // know which value and string to display + property string scrubMode: "" + + // scrubRangeWidth defines both the visual scrubber width and the effective + // input range. The 0.15 margin on each side of rootitem.width matches the + // margin formula in QuickPanelToggle.updateValue so the visual bar + // boundaries and finger position always correspond truthfully. + readonly property real scrubRangeWidth: width * 0.80 + readonly property real scrubCoverHeight: Dims.l(20) + + readonly property real scrubCoverX: (width - scrubRangeWidth) / 2 + readonly property real scrubCoverY: (height - scrubCoverHeight) / 2 + + // Stored valueMeter geometry captured at scrub start for symmetric reverse morph + property real morphStartX: 0 + property real morphStartY: 0 + property real morphStartW: 0 + property real morphStartH: 0 + property color morphStartColor: Qt.rgba(1, 1, 1, 0.3) + + // -- Morph animation orchestration -- + // On scrub start: snapshot valueMeter position, set morphRect there, then + // animate it to scrubCover geometry while the panel fades out via Behaviors. + // scrubCover fades in after the morph is nearly complete. + // On scrub end: reverse — scrubCover fades out, morphRect shrinks back to + // valueMeter geometry as panel fades back in. + onActiveScrubChanged: { + if (activeScrub) { + scrubEventGuard = true + guardTimer.stop() + scrubEndAnim.stop() + var pos = valueMeter.mapToItem(rootitem, 0, 0) + morphStartX = pos.x + morphStartY = pos.y + morphStartW = valueMeter.width + morphStartH = valueMeter.height + morphRect.x = morphStartX + morphRect.y = morphStartY + morphRect.width = morphStartW + morphRect.height = morphStartH + // onActiveScrubChanged — start morph at valueMeter fillColor + morphRect.color = Qt.rgba(valueMeter.fillColor.r, valueMeter.fillColor.g, valueMeter.fillColor.b, 1.0) + morphRect.opacity = valueMeter.fillColor.a + morphRect.visible = true + scrubCover.opacity = 0.0 + scrubStartAnim.start() + } else { + scrubStartAnim.stop() + scrubEndAnim.start() + guardTimer.restart() + } + } + + ParallelAnimation { + id: scrubStartAnim + NumberAnimation { target: morphRect; property: "opacity"; to: 0.75; duration: 250 } + NumberAnimation { target: morphRect; property: "x"; to: scrubCoverX; duration: 250; easing.type: Easing.InOutQuad } + NumberAnimation { target: morphRect; property: "y"; to: scrubCoverY; duration: 250; easing.type: Easing.InOutQuad } + NumberAnimation { target: morphRect; property: "width"; to: scrubRangeWidth; duration: 250; easing.type: Easing.InOutQuad } + NumberAnimation { target: morphRect; property: "height"; to: scrubCoverHeight; duration: 250; easing.type: Easing.InOutQuad } + // Color transitions from valueMeter fill light to scrubCover dark during expand + ColorAnimation { target: morphRect; property: "color"; to: Qt.rgba(0.13, 0.13, 0.13, 0.75); duration: 250 } + SequentialAnimation { + // scrubCover fades in after morphRect has nearly reached final geometry + PauseAnimation { duration: 150 } + NumberAnimation { target: scrubCover; property: "opacity"; to: 1.0; duration: 100 } + } + } + + SequentialAnimation { + id: scrubEndAnim + ParallelAnimation { + NumberAnimation { target: scrubCover; property: "opacity"; to: 0.0; duration: 100 } + SequentialAnimation { + PauseAnimation { duration: 50 } + ParallelAnimation { + NumberAnimation { target: morphRect; property: "x"; to: morphStartX; duration: 250; easing.type: Easing.InOutQuad } + NumberAnimation { target: morphRect; property: "y"; to: morphStartY; duration: 250; easing.type: Easing.InOutQuad } + NumberAnimation { target: morphRect; property: "width"; to: morphStartW; duration: 250; easing.type: Easing.InOutQuad } + NumberAnimation { target: morphRect; property: "height"; to: morphStartH; duration: 250; easing.type: Easing.InOutQuad } + ColorAnimation { target: morphRect; property: "color"; to: morphStartColor; duration: 250 } + NumberAnimation { target: morphRect; property: "opacity"; to: valueMeter.fillColor.a; duration: 250 } + } + } + } + ScriptAction { script: morphRect.visible = false } + } + MceBatteryLevel { id: batteryChargePercentage } MceBatteryState { id: batteryChargeState } MceChargerType { id: mceChargerType } - readonly property int volume: volumeControl ? (volumeControl.maximumVolume ? Math.round((volumeControl.volume / volumeControl.maximumVolume) * 100) : 0) :0 + readonly property int volume: volumeControl ? (volumeControl.maximumVolume ? Math.round((volumeControl.volume / volumeControl.maximumVolume) * 100) : 0) : 0 function setVolume(volume) { volumeControl.volume = Math.round((volume / 100) * volumeControl.maximumVolume); @@ -295,6 +401,10 @@ Item { interactive: false boundsBehavior: Flickable.StopAtBounds spacing: Dims.l(4) + opacity: activeScrub ? 0.0 : 1.0 + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } readonly property var allToggles: { return fixedToggles.value @@ -329,6 +439,22 @@ Item { Component.onCompleted: positionViewAtBeginning() } + // Live value label shown in the fixedRow area during scrub + Label { + id: scrubValueLabel + anchors.centerIn: options.value.batteryBottom ? fixedRow : valueMeter + opacity: activeScrub ? 0.9 : 0.0 + text: scrubMode === "brightness" ? displaySettings.brightness + "%" : volume + "%" + font { + pixelSize: Dims.l(12) + family: "Noto Sans" + styleName: "SemiCondensed SemiBold" + } + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } + } + Connections { target: grid function onCurrentVerticalPosChanged() { @@ -349,6 +475,10 @@ Item { interactive: true boundsBehavior: Flickable.StopAtBounds spacing: Dims.l(4) + opacity: activeScrub ? 0.0 : 1.0 + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } readonly property var allToggles: { return sliderToggles.value @@ -396,6 +526,71 @@ Item { } } + // morphRect — plain pill that animates between valueMeter geometry and + // scrubCover geometry during scrub start/end transitions. Color animates + // from valueMeter fill light to scrubCover dark on expand, and reverse on + // collapse. Declared before scrubCover so scrubCover paints on top. + Rectangle { + id: morphRect + visible: false + color: "#eee0e0e0" + radius: height / 2 + // x, y, width, height are set imperatively in onActiveScrubChanged + } + + // scrubCover — the actual interactive scrubber display. Position and size + // are fixed at scrubRangeWidth/scrubCoverHeight; only opacity is animated + // by scrubStartAnim/scrubEndAnim. Declared after morphRect so it paints + // above and cleanly takes over at morph completion. + Rectangle { + id: scrubCover + x: scrubCoverX + y: scrubCoverY + width: scrubRangeWidth + height: scrubCoverHeight + radius: height / 2 + color: Qt.rgba(0, 0, 0, 0) + opacity: 0 // managed exclusively by scrubStartAnim / scrubEndAnim + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: scrubCover.width + height: scrubCover.height + radius: scrubCover.radius + } + } + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0.13, 0.13, 0.13, 0.75) + } + + // Value fill — width tracks current brightness or volume fraction. + // Color matches ValueMeter: white when batteryColored off, accent when on. + Rectangle { + id: scrubFill + height: parent.height + width: parent.width * Math.max(0, Math.min(1, + (scrubMode === "brightness" ? displaySettings.brightness : volume) / 100)) + color: !options.value.batteryColored ? Qt.rgba(1, 1, 1, 0.3) : "#4CA6005F" + Behavior on width { + NumberAnimation { duration: 80; easing.type: Easing.OutQuad } + } + } + + // Event-swallowing MouseArea covering the full scrubCover area. + // Enabled by scrubEventGuard which outlives activeScrub by guardTimer + // interval, covering the fade-out window where stray Qt touch events + // could otherwise reach the toggles underneath. + MouseArea { + anchors.fill: parent + enabled: scrubEventGuard + // No handlers needed — default MouseArea behaviour accepts and + // swallows all mouse/touch events when enabled + } + } + ValueMeter { id: valueMeter width: toggleSize * 1.8 @@ -403,40 +598,27 @@ Item { valueLowerBound: 0 valueUpperBound: 100 anchors.horizontalCenter: parent.horizontalCenter - property Timer fadeOutTimer: fadeOutTimer - - Timer { - id: fadeOutTimer - interval: 2000 - onTriggered: { - valueMeter.state = "" - - // Signal toggles to reset direction - valueMeter.resetDirection() - } - } + // Always shows battery. Fades out during scrub while scrubCover takes over. value: batteryChargePercentage.percent + opacity: activeScrub ? 0.0 : 1.0 - // Signal to notify toggles to reset direction - signal resetDirection - - // Animate value changes for smooth fill width transitions + // Disable value animation during scrub — the fill would constantly + // restart the 250ms animation and permanently lag behind the label Behavior on value { + enabled: !activeScrub NumberAnimation { duration: 250 easing.type: Easing.InOutQuad } } - isIncreasing: valueMeter.state == "" ? isCharging : false - enableAnimations: options.value.batteryAnimation && valueMeter.state == "" + isIncreasing: isCharging + enableAnimations: options.value.batteryAnimation particleDesign: options.value.particleDesign fillColor: { if (!options.value.batteryColored) return Qt.rgba(1, 1, 1, 0.3) - if (!valueMeter.state == "") return "#4CA6005F" - const percent = batteryChargePercentage.percent if (percent <= 20) { const t = (20 - percent) / 20 @@ -449,48 +631,17 @@ Item { return Qt.rgba(0, 1, 0, 0.3) } - // Use behavior for fill color transitions Behavior on fillColor { ColorAnimation { duration: 300 } } - states: [ - State { - name: "brightness" - PropertyChanges { target: valueMeter; value: displaySettings.brightness } - PropertyChanges { target: flashIcon; visible: false } - //% "Brightness" - PropertyChanges { target: valueMeterCaption; text: qsTrId("id-brightness") } - PropertyChanges { target: flashIcon; visible: false } - }, - State { - name: "volume" - PropertyChanges { target: valueMeter; value: volume } - //% "Volume" - PropertyChanges { target: valueMeterCaption; text: qsTrId("id-volume") } - } - ] - transitions: Transition { - SequentialAnimation { - ParallelAnimation { - NumberAnimation { target: valueMeter; property: "opacity"; duration: 125; to: 0 } - NumberAnimation { target: valueMeterCaption; property: "opacity"; duration: 125; to: 0 } - NumberAnimation { target: flashIcon; property: "opacity"; duration: 125; to: 0 } - } - ParallelAnimation { - PropertyAnimation { target: valueMeter; property: "value"; duration: 0 } - PropertyAnimation { target: valueMeterCaption; property: "text"; duration: 0 } - PropertyAnimation { target: flashIcon; property: "visible"; duration: 0 } - } - ParallelAnimation { - NumberAnimation { target: valueMeter; property: "opacity"; duration: 125; to: 1 } - NumberAnimation { target: valueMeterCaption; property: "opacity"; duration: 125; to: 1 } - NumberAnimation { target: flashIcon; property: "opacity"; duration: 125; to: 1 } - } - } + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } } } + // flashIcon cannot share a Behavior on opacity with its own + // SequentialAnimation on opacity, so visibility is toggled instead Icon { id: flashIcon width: Dims.l(8) @@ -498,7 +649,7 @@ Item { name: "ios-flash" anchors.centerIn: valueMeter y: -Dims.l(10) - visible: isCharging + visible: isCharging && !activeScrub opacity: 1.0 SequentialAnimation on opacity { @@ -512,6 +663,7 @@ Item { Label { id: valueMeterCaption anchors.horizontalCenter: parent.horizontalCenter + opacity: activeScrub ? 0.0 : 1.0 font { pixelSize: Dims.l(9) @@ -521,8 +673,37 @@ Item { text: batteryChargePercentage.percent + "%" + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } } + // Mode caption shown in place of valueMeterCaption during scrub. + // anchors.centerIn: valueMeterCaption tracks the batteryBottom/batteryTop + // layout state automatically without needing its own AnchorChanges entries. + Label { + id: scrubCaptionLabel + anchors{ + centerIn: options.value.batteryBottom ? valueMeter : fixedRow + verticalCenterOffset: -Dims.l(3) + } + opacity: activeScrub ? 0.9 : 0.0 + + font { + pixelSize: Dims.l(12) + family: "Noto Sans" + styleName: "Condensed Medium" + } + + //% "Brightness" + text: scrubMode === "brightness" ? qsTrId("id-brightness") : + //% "Volume" + qsTrId("id-volume") + + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } + } PageDot { id: pageDots @@ -530,7 +711,10 @@ Item { anchors.horizontalCenter: parent.horizontalCenter currentIndex: slidingRow.currentIndex dotNumber: slidingRow.rowCount - opacity: 0.5 + opacity: activeScrub ? 0.0 : 0.5 + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } } RemorseTimer { @@ -550,33 +734,33 @@ Item { icon: "ios-sunny" checkable: true rangeBased: true - rangeMin: 0 + rangeMin: 10 rangeMax: 100 rangeStepSize: 10 + scrubWidth: rootitem.width checked: displaySettings.brightness > 10 - + onClicked: { - if(checked) { + if (wasScrubbing) { wasScrubbing = false; return } + if (checked) { displaySettings.brightness = rangeMin - isIncreasing = true } else { displaySettings.brightness = rangeMax - isIncreasing = false } } + // binding keeps rangeValue current so scrub always starts from the + // real current brightness, not a snapshot from a previous interaction rangeValue: displaySettings.brightness - - onPressed: valueMeter.state = "brightness" - onReleased: valueMeter.fadeOutTimer.restart() - onRangeValueChanged: displaySettings.brightness = rangeValue - Connections { - target: valueMeter - function onResetDirection() { - isIncreasing = true + onScrubbingChanged: { + if (scrubbing) { + rootitem.scrubMode = "brightness" + rootitem.activeScrub = true + } else { + rootitem.activeScrub = false } } } @@ -590,7 +774,7 @@ Item { checked: vibrationEnabled.value onClicked: { - if(checked) { + if (checked) { vibrationEnabled.value = false } else { vibrationEnabled.value = true; @@ -613,8 +797,12 @@ Item { icon: wifiStatus.connected ? "ios-wifi" : "ios-wifi-outline" checkable: true + // checked binds directly to the system source of truth. No Connections + // block needed to stay in sync with external state changes. checked: wifiStatus.powered + // !checked reads the value before the click flips it, so this is + // effectively: set powered to whatever it currently isn't onClicked: { wifiStatus.powered = !checked } @@ -645,81 +833,59 @@ Item { QuickPanelToggle { id: soundToggle checkable: true - rangeBased: true rangeMin: 0 rangeMax: 100 rangeStepSize: 10 - - onPressAndHold: { - rangeValue = volume - - if (preMuteLevel.value > 0) { - const tempVolume = volume; - setVolume(preMuteLevel.value); - preMuteLevel.value = tempVolume; - - toggled = true; - } - } - - onPressed: valueMeter.state = "volume" - onReleased: { - valueMeter.fadeOutTimer.restart() - - if (volume > 0 && preMuteLevel.value === 0) { - soundDelayTimer.start(); - } - } - - - onRangeValueChanged: setVolume(rangeValue) + scrubWidth: rootitem.width icon: preMuteLevel.value > 0 || volume === 0 ? "ios-sound-indicator-mute" : volume > 70 ? "ios-sound-indicator-high" : volume > 30 ? "ios-sound-indicator-mid" : volume > 0 ? "ios-sound-indicator-low" : "ios-sound-indicator-off" - onClicked: { - const tempVolume = volume; - let targetVolume = preMuteLevel.value; + // checked reflects mute state only — volume at 0% while unmuted + // is silent but not muted and should not deactivate the toggle + checked: !(preMuteLevel.value > 0 || volume === 0) - if (tempVolume === 0 && targetVolume === 0) { - targetVolume = 100; + onClicked: { + if (wasScrubbing) { wasScrubbing = false; return } + const tempVolume = volume + let targetVolume = preMuteLevel.value + if (tempVolume === 0 && targetVolume === 0) targetVolume = 100 + setVolume(targetVolume) + preMuteLevel.value = tempVolume + if (targetVolume > 0) soundDelayTimer.start() + } + + rangeValue: volume + onRangeValueChanged: { + if (scrubbing) { + // Scrubbing to any value > 0 while muted clears the mute + // state — the user is explicitly setting a volume level + if (rangeValue > 0 && preMuteLevel.value > 0) { + preMuteLevel.value = 0 + } + setVolume(rangeValue) } + } - setVolume(targetVolume); - preMuteLevel.value = tempVolume; - - if (targetVolume > 0) { - soundDelayTimer.start(); + onScrubbingChanged: { + if (scrubbing) { + rootitem.scrubMode = "volume" + rootitem.activeScrub = true + } else { + rootitem.activeScrub = false + if (rangeValue > 0) soundDelayTimer.start() } } - checked: !(preMuteLevel.value > 0 || volume === 0) - Timer { id: soundDelayTimer interval: 150 repeat: false onTriggered: unmuteSound.play() } - - Connections { - target: volumeControl - function onVolumeChanged() { - if(!pressed) { - rangeValue = volume - } - } - } - - Connections { - target: valueMeter - function onResetDirection() { - isIncreasing = true - } - } } } @@ -736,10 +902,8 @@ Item { checked: actualState onClicked: { - if(checked) { - // Store pre-cinema states + if (!checked) { preCinemaAodState.value = alwaysOnDisplay.value; - // Mute sound if available if (DeviceSpecs.hasSpeaker && !isMuted) { preMuteLevel.value = volume; setVolume(0); @@ -747,10 +911,8 @@ Item { alwaysOnDisplay.value = false; displaySettings.lowPowerModeEnabled = false; } else { - // Restore pre-cinema states alwaysOnDisplay.value = preCinemaAodState.value; displaySettings.lowPowerModeEnabled = alwaysOnDisplay.value; - // Restore sound if (DeviceSpecs.hasSpeaker && isMuted) { setVolume(preMuteLevel.value); preMuteLevel.value = 0; diff --git a/src/qml/quickpanel/QuickPanelToggle.qml b/src/qml/quickpanel/QuickPanelToggle.qml index e4c5b05b..d48684fb 100644 --- a/src/qml/quickpanel/QuickPanelToggle.qml +++ b/src/qml/quickpanel/QuickPanelToggle.qml @@ -48,48 +48,63 @@ MouseArea { pressAndHoldInterval: 300 - property bool isIncreasing: true + // -- Scrub interaction -- + // scrubWidth must be set by the caller to the available drag distance + // (typically rootitem.width) so the full range maps across the panel. + property bool scrubbing: false + property int scrubWidth: 0 + // wasScrubbing is a backup guard for the case where onClicked does fire + // after a short drag. For long drags Qt may not fire onClicked at all, + // in which case scrubEventGuard on rootitem swallows the stray event. + property bool wasScrubbing: false + + // Clear wasScrubbing at the start of a new press so the next full + // click cycle works normally after a long drag that skipped onClicked + onPressed: { + if (wasScrubbing) wasScrubbing = false + } + onPressAndHold: { - if (!rangeBased) return; - holdTimer.start() + if (!rangeBased) return + scrubbing = true + wasScrubbing = true + preventStealing = true + updateValue(mapToItem(rootitem, mouseX, 0).x) } - onReleased: { - holdTimer.stop() - directionChangeTimer.stop() + onPositionChanged: { + // Before hold is confirmed, preventStealing is false so the parent + // ListView steals horizontal swipes naturally. Once scrubbing is + // active, all position events are ours. + if (!scrubbing) return + updateValue(mapToItem(rootitem, mouseX, 0).x) } - onCanceled: { - holdTimer.stop() - directionChangeTimer.stop() + // onReleased is the unconditional cleanup — clears scrub state regardless + // of which path activated it. wasScrubbing intentionally NOT cleared here + // since onClicked fires after onReleased and needs to read it. + onReleased: { + scrubbing = false + preventStealing = false } - Timer { - id: holdTimer - interval: 300 - repeat: true - triggeredOnStart: true - onTriggered: { - const newValue = rangeValue + (isIncreasing ? 1 : -1) * rangeStepSize - rangeValue = Math.max(rangeMin, Math.min(rangeMax, newValue)) - if (rangeValue >= rangeMax || rangeValue <= rangeMin) { - holdTimer.stop() - isIncreasing = !isIncreasing - directionChangeTimer.start() - } - } + onCanceled: { + scrubbing = false + wasScrubbing = false + preventStealing = false } - Timer { - id: directionChangeTimer - //delay after direction is changed - interval: 1000 - repeat: false - onTriggered: { - if (ma.pressed) - holdTimer.start() - } + // Snap-to-position with fat-finger margins: maps absolute panel x to the + // value range. The 0.15 margin on each side matches scrubRangeWidth in + // QuickPanel so the visual bar boundaries and finger position correspond. + function updateValue(mx) { + var margin = scrubWidth * 0.15 + var f = Math.max(0, Math.min(1, (mx - margin) / Math.max(1, scrubWidth - 2 * margin))) + var newVal = rangeMin + f * (rangeMax - rangeMin) + // | 0 casts to int — Qt6 rejects assigning a JS Number (double) to a + // property int even when the value is whole (Math.round returns double) + rangeValue = Math.max(rangeMin, Math.min(rangeMax, Math.round(newVal / rangeStepSize) * rangeStepSize)) | 0 } Rectangle { @@ -108,4 +123,3 @@ MouseArea { opacity: ma.pressed ? 0.5 : ma.checked ? 1 : (ma.checkable ? 0.3 : 1) } } - From 0d963c256e96d520a550b2849f80db003738c6c8 Mon Sep 17 00:00:00 2001 From: moWerk Date: Sun, 19 Jul 2026 13:40:56 +0200 Subject: [PATCH 2/2] quickpanel: Haptic confirmation on scrub activation Without feedback at hold-detection, users wait for the visual morph before daring to move - longer than the actual arming time. Play the press haptic the moment onPressAndHold fires so the finger knows the scrub is live; value tracking already starts before the first animation frame (input sensing leads, visuals follow). Co-Authored-By: Claude Fable 5 --- src/qml/quickpanel/QuickPanelToggle.qml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/qml/quickpanel/QuickPanelToggle.qml b/src/qml/quickpanel/QuickPanelToggle.qml index d48684fb..90072bb5 100644 --- a/src/qml/quickpanel/QuickPanelToggle.qml +++ b/src/qml/quickpanel/QuickPanelToggle.qml @@ -70,6 +70,9 @@ MouseArea { scrubbing = true wasScrubbing = true preventStealing = true + // haptic confirms the scrub is armed the moment the hold is + // detected — input sensing leads, the visual morph follows + feedback.play() updateValue(mapToItem(rootitem, mouseX, 0).x) }