import QtQuick // Taken from hyprfm: https://github.com/soyeb-jim285/hyprfm/blob/main/src/qml/views/KineticWheelScroller.qml // Adapted to fit this project where needed. // Deliberately not a MouseArea: a MouseArea claims the arrow cursor for its // whole area, which silently killed every cursorShape in the views this // overlays (column resize grips, pointer hands). A WheelHandler on a plain // Item takes wheel events without touching the cursor. Item { id: root required property var flickable property real wheelStep: 56 property real mouseWheelMultiplier: 1.0 property real touchpadMultiplier: 1.35 property real minVelocity: 480 property real maxVelocity: 5200 property real kineticGain: 1.35 property real smoothing: 0.72 property real edgeEpsilon: 1.0 // Browser-style overscroll: larger visible rubber-band, softer // resistance, and a springy settle on release. property real overshootLimit: 140 property real overshootResistance: 0.38 property real maxOvershootInput: 520 // Bounce magnitude per unit of impact velocity. Keep this deliberately // small: slow edge hits should settle, not fire a visibly faster bounce. property real kineticBounceGain: 0.018 property real maxKineticBounceInput: 90 // Minimum impact velocity that actually produces a visible bounce. // Very slow landings skip bounce entirely — matches what browsers do // when a fling decays to near-zero before reaching the edge. property real minBounceVelocity: 260 property real velocity: 0 property double lastWheelTimestamp: 0 property bool kineticCandidate: false property real overshootInput: 0 property real overshootReleaseVelocity: 0 property real pendingKineticBounceVelocity: 0 // Set by runKineticGlide when the glide will clamp against an edge. // It is the glide velocity at the instant of impact, which drives the // elastic bounce amount and duration — browser-style: slow impact // makes a small gentle bounce; fast impact makes a big springy one. property real pendingImpactVelocity: 0 // Browser-style exponential-feeling glide replaces the linear // flickDeceleration decay. Tail uses OutQuart — soft landing. property real kineticTau: 0.42 property real mouseGlideTime: 0.09 // decay time constant (s); higher = longer coast property real mouseAccelWindow: 140 // ms between notches that counts as "continuous" property real mouseAccelStep: 0.15 // extra impulse multiplier per consecutive notch property real mouseMaxAccel: 2.5 property real mouseSnap: 0.2 // fraction of each notch applied instantly (0 = fully glided, 1 = no smoothing) property real mouseVelocity: 0 property int mouseDir: 0 property int mouseStreak: 0 property double mouseLastTime: 0 // Escape hatch if auto-detection misjudges a device property bool forceMouseWheel: false readonly property real visualOvershoot: overshootInput < 0 ? rubberDistance(-overshootInput) : -rubberDistance(overshootInput) signal scrollStarted WheelHandler { id: wheelHandler acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad // `blocking` decides whether the event continues to the Flickable // underneath; handleWheel() flips it per event (the old MouseArea // code toggled wheel.accepted for the same purpose). onWheel: wheel => root.handleWheel(wheel) } function isMouseWheel(wheel) { if (forceMouseWheel) return true; var ay = wheel.angleDelta.y; if (ay === 0) return false; return Math.abs(ay) % 120 === 0; } function minimumContentY() { if (!flickable) return 0; return flickable.originY; } function maximumContentY() { if (!flickable) return 0; return Math.max(minimumContentY(), minimumContentY() + flickable.contentHeight - flickable.height); } function boundedContentY(value) { return Math.max(minimumContentY(), Math.min(maximumContentY(), value)); } function rubberDistance(distance) { if (distance <= 0) return 0; var scaled = (distance * overshootResistance) / overshootLimit; return overshootLimit * (1 - 1 / (1 + scaled)); } function setOvershootInput(value) { overshootInput = Math.max(-maxOvershootInput, Math.min(maxOvershootInput, value)); } function hasOvershoot() { return Math.abs(overshootInput) > edgeEpsilon; } function absorbOvershoot(delta) { if (!hasOvershoot()) return delta; var sameDirection = (overshootInput < 0 && delta < 0) || (overshootInput > 0 && delta > 0); if (sameDirection) { setOvershootInput(overshootInput + delta); return 0; } var combined = overshootInput + delta; if ((overshootInput < 0 && combined < 0) || (overshootInput > 0 && combined > 0)) { setOvershootInput(combined); return 0; } setOvershootInput(0); return combined; } function deltaFor(wheel) { var rawDelta; if (isMouseWheel(wheel)) rawDelta = (wheel.angleDelta.y / 120.0) * wheelStep * mouseWheelMultiplier; else rawDelta = wheel.pixelDelta.y !== 0 ? wheel.pixelDelta.y * touchpadMultiplier : (wheel.angleDelta.y / 120.0) * wheelStep * mouseWheelMultiplier; return wheel.inverted ? rawDelta : -rawDelta; } function shouldUseKinetic(wheel, gap) { return wheel.pixelDelta.y !== 0 || (wheel.phase !== Qt.NoScrollPhase && wheel.angleDelta.y === 0); } function scrollBy(delta) { if (!flickable || Math.abs(delta) < 0.01) return; kineticBounceSequence.stop(); bounceBackAnimation.stop(); var remainingDelta = absorbOvershoot(delta); if (Math.abs(remainingDelta) < 0.01) return; var minY = minimumContentY(); var maxY = maximumContentY(); var next = flickable.contentY + remainingDelta; if (next < minY) { flickable.contentY = minY; setOvershootInput(overshootInput + next - minY); return; } if (next > maxY) { flickable.contentY = maxY; setOvershootInput(overshootInput + next - maxY); return; } flickable.contentY = next; } function remainingDistanceFor(velocityValue) { if (!flickable) return 0; if (velocityValue > 0) return Math.max(0, maximumContentY() - flickable.contentY); if (velocityValue < 0) return Math.max(0, flickable.contentY - minimumContentY()); return 0; } function clampedContentVelocity(velocityValue) { if (!flickable || velocityValue === 0) return 0; var remaining = remainingDistanceFor(velocityValue); if (remaining <= edgeEpsilon) return 0; var maxAllowed = Math.sqrt(2 * flickable.flickDeceleration * remaining); var magnitude = Math.min(Math.abs(velocityValue), maxAllowed); return velocityValue < 0 ? -magnitude : magnitude; } function resetState() { velocity = 0; lastWheelTimestamp = 0; kineticCandidate = false; pendingKineticBounceVelocity = 0; pendingImpactVelocity = 0; overshootReleaseVelocity = 0; finishTimer.stop(); kineticAnim.stop(); kineticBounceSequence.stop(); bounceBackAnimation.stop(); stopMouseGlide(); } function bounceBack() { if (!hasOvershoot()) { setOvershootInput(0); overshootReleaseVelocity = 0; return; } var mag = Math.min(1.0, Math.abs(overshootInput) / maxOvershootInput); var speed = Math.abs(overshootReleaseVelocity); var duration = speed >= minBounceVelocity ? Math.round(Math.max(280, Math.min(620, Math.abs(overshootInput) / speed * 1600))) : Math.round(380 + mag * 260); kineticBounceSequence.stop(); bounceBackAnimation.stop(); bounceBackAnimation.duration = duration; bounceBackAnimation.from = overshootInput; bounceBackAnimation.to = 0; overshootReleaseVelocity = 0; bounceBackAnimation.start(); } function triggerKineticBounce(contentVelocity) { if (!flickable || contentVelocity === 0) return; var atTop = flickable.contentY <= minimumContentY() + edgeEpsilon; var atBottom = flickable.contentY >= maximumContentY() - edgeEpsilon; if ((contentVelocity < 0 && !atTop) || (contentVelocity > 0 && !atBottom)) return; var speed = Math.abs(contentVelocity); var effectiveVelocity = contentVelocity; if (speed < minBounceVelocity) effectiveVelocity = (contentVelocity < 0 ? -1 : 1) * Math.max(90, speed * 0.6); var target = Math.max(-maxKineticBounceInput, Math.min(maxKineticBounceInput, effectiveVelocity * kineticBounceGain)); if (Math.abs(target) < 2.5) target = contentVelocity < 0 ? -2.5 : 2.5; var mag = Math.min(1.0, Math.abs(target) / maxKineticBounceInput); var inDur = Math.round(150 + mag * 120); var outDur = Math.round(320 + mag * 300); kineticBounceSequence.stop(); bounceBackAnimation.stop(); overshootReleaseVelocity = 0; overshootInput = 0; kineticBounceIn.from = 0; kineticBounceIn.to = target; kineticBounceIn.duration = inDur; kineticBounceOut.from = target; kineticBounceOut.to = 0; kineticBounceOut.duration = outDur; kineticBounceSequence.start(); } function stopAndSettle() { kineticAnim.stop(); if (flickable && flickable.flicking) flickable.cancelFlick(); resetState(); bounceBack(); } function runKineticGlide(contentVelocity) { // contentVelocity > 0 means the content should scroll downward // (contentY increases), matching deltaFor()'s sign convention. if (!flickable || contentVelocity === 0) { pendingImpactVelocity = 0; return false; } var current = flickable.contentY; var unclampedTarget = current + contentVelocity * kineticTau; var target = boundedContentY(unclampedTarget); var distUnclamped = Math.abs(unclampedTarget - current); var distActual = Math.abs(target - current); if (distActual < 4) { pendingImpactVelocity = 0; return false; } // If the glide is clipped by an edge, compute the velocity at the // moment of impact using the OutQuart velocity profile. That is // the velocity the bounce should feed on — gentle impacts get // gentle bounces, hard impacts get big springy ones. var hitsEdge = distActual + 0.5 < distUnclamped; if (hitsEdge && distUnclamped > 0) { var f = distActual / distUnclamped; // fraction covered // OutQuart: y = 1 - (1-x)^4, so velocity fraction at distance // fraction f equals (1-f)^(3/4) (times the constant peak). var velFraction = Math.pow(Math.max(0, 1 - f), 0.75); pendingImpactVelocity = contentVelocity * velFraction; } else { pendingImpactVelocity = 0; } kineticAnim.stop(); kineticAnim.from = current; kineticAnim.to = target; // Duration from distance and velocity; clamped so short flicks // stay snappy and long flicks don't run forever. kineticAnim.duration = Math.round(Math.min(1400, Math.max(180, distActual / Math.abs(contentVelocity) * 1800))); kineticAnim.start(); return true; } function atEdgeFor(contentVelocity) { if (!flickable) return false; if (contentVelocity > 0) return flickable.contentY >= maximumContentY() - edgeEpsilon; if (contentVelocity < 0) return flickable.contentY <= minimumContentY() + edgeEpsilon; return false; } function finishScroll() { if (!flickable) return; finishTimer.stop(); if (hasOvershoot()) { velocity = 0; lastWheelTimestamp = 0; kineticCandidate = false; pendingKineticBounceVelocity = 0; pendingImpactVelocity = 0; kineticAnim.stop(); bounceBack(); return; } var rawContentVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, velocity * kineticGain)); var contentVelocity = clampedContentVelocity(rawContentVelocity); var useKinetic = kineticCandidate && Math.abs(rawContentVelocity) >= minVelocity; velocity = 0; lastWheelTimestamp = 0; kineticCandidate = false; if (useKinetic) { if (atEdgeFor(rawContentVelocity)) { // Already at the edge — fling bounces immediately. pendingKineticBounceVelocity = 0; triggerKineticBounce(rawContentVelocity); } else if (Math.abs(contentVelocity) >= minVelocity && runKineticGlide(contentVelocity)) { // Glide started — bounce fires on kineticAnim.onFinished // if it lands at an edge. pendingKineticBounceVelocity = contentVelocity; } else { pendingKineticBounceVelocity = 0; flickable.returnToBounds(); } } else { pendingKineticBounceVelocity = 0; flickable.returnToBounds(); } } function handleWheel(wheel) { wheelHandler.blocking = true; if (!enabled || !visible || !flickable || !flickable.visible || !flickable.interactive) { wheelHandler.blocking = false; return; } if (wheel.modifiers !== Qt.NoModifier) { wheelHandler.blocking = false; return; } if (maximumContentY() <= minimumContentY()) { wheelHandler.blocking = false; return; } var delta = deltaFor(wheel); if (delta === 0) { wheelHandler.blocking = false; return; } // Cancel any in-flight glide — user input always wins, position // stays where it is, then new delta is applied from there. if (kineticAnim.running) { pendingKineticBounceVelocity = 0; pendingImpactVelocity = 0; kineticAnim.stop(); } kineticBounceSequence.stop(); bounceBackAnimation.stop(); if (flickable.flicking) { pendingKineticBounceVelocity = 0; pendingImpactVelocity = 0; flickable.cancelFlick(); } var now = Date.now(); var isTouchpad = !isMouseWheel(wheel) && (wheel.pixelDelta.y !== 0 || (wheel.phase !== Qt.NoScrollPhase && wheel.angleDelta.y === 0)); console.log("wheel", wheel.angleDelta.y, wheel.pixelDelta.y, wheel.phase); if (!isTouchpad) { velocity = 0; lastWheelTimestamp = 0; kineticCandidate = false; scrollStarted(); handleMouseDelta(delta, now); return; } // Touchpad path (unchanged below), but cancel any mouse glide first stopMouseGlide(); var gap = lastWheelTimestamp > 0 ? now - lastWheelTimestamp : 16; var dt = Math.max(8, Math.min(24, gap)); var instantVelocity = (delta / dt) * 1000; kineticCandidate = kineticCandidate || shouldUseKinetic(wheel, gap); scrollStarted(); scrollBy(delta); if (hasOvershoot()) { overshootReleaseVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, instantVelocity)); velocity = 0; kineticCandidate = false; lastWheelTimestamp = now; finishTimer.restart(); return; } velocity = lastWheelTimestamp <= 0 ? instantVelocity : velocity * (1.0 - smoothing) + instantVelocity * smoothing; lastWheelTimestamp = now; finishTimer.restart(); if (wheel.phase === Qt.ScrollEnd) finishScroll(); } function stopMouseGlide() { mouseGlide.stop(); mouseVelocity = 0; mouseDir = 0; mouseStreak = 0; mouseLastTime = 0; } function handleMouseDelta(delta, now) { var dir = delta > 0 ? 1 : -1; // Edge or active overshoot: existing rubber-band path if (hasOvershoot() || atEdgeFor(dir)) { stopMouseGlide(); scrollBy(delta); overshootReleaseVelocity = 0; finishTimer.restart(); return; } var continuous = dir === mouseDir && (now - mouseLastTime) <= mouseAccelWindow; if (!continuous) mouseVelocity = 0; // new gesture or reversal: start clean mouseStreak = continuous ? mouseStreak + 1 : 0; // Compute the gap BEFORE overwriting mouseLastTime var gap = mouseLastTime > 0 ? Math.max(12, Math.min(mouseAccelWindow, now - mouseLastTime)) : 0; mouseDir = dir; mouseLastTime = now; var accel = Math.min(mouseMaxAccel, 1 + mouseStreak * mouseAccelStep); // --- velocity update (this is the part that changed) --- // Part of the notch lands immediately var instant = delta * mouseSnap; flickable.contentY = boundedContentY(flickable.contentY + instant); // The rest becomes glide velocity, stacked onto the current velocity var glide = delta * (1 - mouseSnap) * accel; var v = mouseVelocity + glide / mouseGlideTime; // While notches keep arriving, don't fall below the rate they imply if (continuous && gap > 0) { var rate = glide / (gap / 1000); if (Math.abs(rate) > Math.abs(v)) v = rate; } mouseVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, v)); // --- end velocity update --- if (!mouseGlide.running) mouseGlide.start(); } function stepMouseGlide(dt) { if (!flickable || flickable.dragging) { stopMouseGlide(); return; } dt = Math.min(dt, 0.05); var decay = Math.exp(-dt / mouseGlideTime); // Exact integral of v0 * e^(-t/tau) over dt, so it's frame-rate independent var step = mouseVelocity * mouseGlideTime * (1 - decay); var next = flickable.contentY + step; var minY = minimumContentY(); var maxY = maximumContentY(); if (next <= minY || next >= maxY) { var impact = mouseVelocity; flickable.contentY = next <= minY ? minY : maxY; mouseGlide.stop(); mouseVelocity = 0; mouseDir = 0; mouseStreak = 0; triggerKineticBounce(impact); // bounce scales with the real impact speed return; } flickable.contentY = next; mouseVelocity *= decay; if (Math.abs(mouseVelocity) < 40) // was 8 stopMouseGlide(); } FrameAnimation { id: mouseGlide onTriggered: root.stepMouseGlide(frameTime) } Timer { id: finishTimer interval: 45 repeat: false onTriggered: root.finishScroll() } NumberAnimation { id: kineticAnim target: root.flickable property: "contentY" easing.type: Easing.OutQuart onFinished: { // Prefer the impact-time velocity (computed in runKineticGlide) // so the bounce matches the residual energy at the moment the // glide hit the edge — slow landings → soft bounce, fast // landings → big springy bounce. var bv = Math.abs(root.pendingImpactVelocity) > root.edgeEpsilon ? root.pendingImpactVelocity : root.pendingKineticBounceVelocity; root.pendingImpactVelocity = 0; root.pendingKineticBounceVelocity = 0; if (Math.abs(bv) > root.edgeEpsilon) root.triggerKineticBounce(bv); } } NumberAnimation { id: bounceBackAnimation target: root property: "overshootInput" easing.type: Easing.OutCubic } SequentialAnimation { id: kineticBounceSequence NumberAnimation { id: kineticBounceIn target: root property: "overshootInput" easing.type: Easing.OutCubic } NumberAnimation { id: kineticBounceOut target: root property: "overshootInput" easing.type: Easing.OutCubic } } Translate { id: overshootTranslate y: root.visualOvershoot } Connections { target: root.flickable function onInteractiveChanged() { if (!root.flickable.interactive) root.stopAndSettle(); } function onContentHeightChanged() { if (!root.flickable.moving) { root.flickable.contentY = root.boundedContentY(root.flickable.contentY); root.bounceBack(); } } function onHeightChanged() { if (!root.flickable.moving) { root.flickable.contentY = root.boundedContentY(root.flickable.contentY); root.bounceBack(); } } function onVisibleChanged() { if (!root.flickable.visible) root.stopAndSettle(); } function onMovementEnded() { if (Math.abs(root.pendingKineticBounceVelocity) > root.edgeEpsilon) { var bounceVelocity = root.pendingKineticBounceVelocity; root.pendingKineticBounceVelocity = 0; root.triggerKineticBounce(bounceVelocity); } } } Component.onCompleted: { if (!flickable) return; flickable.flickDeceleration = 2800; flickable.maximumFlickVelocity = Math.max(flickable.maximumFlickVelocity, maxVelocity); flickable.contentItem.transform = [overshootTranslate]; } }