Rotary.kt

/*
 * Copyright 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

@file:OptIn(ExperimentalWearFoundationApi::class)

package androidx.wear.compose.foundation.rotary

import android.view.ViewConfiguration
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.rotary.RotaryInputModifierNode
import androidx.compose.ui.input.rotary.RotaryScrollEvent
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.util.fastSumBy
import androidx.compose.ui.util.lerp
import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
import androidx.wear.compose.foundation.lazy.ScalingLazyListState
import androidx.wear.compose.foundation.lazy.inverseLerp
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.sign
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

/**
 * An adapter which connects scrollableState to a rotary input for snapping scroll actions.
 *
 * This interface defines the essential properties and methods required for a scrollable
 * to be controlled by rotary input and perform a snap action.
 *
 */
@ExperimentalWearFoundationApi
// TODO(b/278705775): make it public once haptics and other code is merged.
/* public */ internal interface RotaryScrollAdapter {

    /**
     * The scrollable state used for performing scroll actions in response to rotary events.
     */
    val scrollableState: ScrollableState

    /**
     * Calculates the average size of an item within the scrollable. This is used to
     * estimate scrolling distances for snapping when responding to rotary input.
     *
     * @return The average item size in pixels.
     */
    fun averageItemSize(): Float

    /**
     * Returns the index of the item that is closest to the center.
     */
    fun currentItemIndex(): Int

    /**
     * Returns the offset of the currently centered item from its centered position.
     * This value can be positive or negative.
     *
     * @return The offset of the current item in pixels.
     */
    fun currentItemOffset(): Float

    // TODO(b/326239879) Investigate and test whether this method can be removed.
    /**
     * The total number of items within the scrollable in [scrollableState]
     */
    fun totalItemsCount(): Int
}

/**
 * Defaults for rotary modifiers
 */
@ExperimentalWearFoundationApi
// TODO(b/278705775): make it public once haptics and other code is merged.
/* public */ internal object RotaryDefaults {

    // These values represent the timeframe for a fling event. A bigger value is assigned
    // to low-res input due to the lower frequency of low-res rotary events.
    internal const val lowResFlingTimeframe: Long = 100L
    internal const val highResFlingTimeframe: Long = 30L
}

/**
 * An implementation of rotary scroll adapter for ScalingLazyColumn
 */
@OptIn(ExperimentalWearFoundationApi::class)
internal class ScalingLazyColumnRotaryScrollAdapter(
    override val scrollableState: ScalingLazyListState
) : RotaryScrollAdapter {

    /**
     * Calculates the average item height by averaging the height of visible items.
     */
    override fun averageItemSize(): Float {
        val visibleItems = scrollableState.layoutInfo.visibleItemsInfo
        return (visibleItems.fastSumBy { it.unadjustedSize } / visibleItems.size).toFloat()
    }

    /**
     * Current (centered) item index
     */
    override fun currentItemIndex(): Int = scrollableState.centerItemIndex

    /**
     * The offset from the item center.
     */
    override fun currentItemOffset(): Float = scrollableState.centerItemScrollOffset.toFloat()

    /**
     * The total count of items in ScalingLazyColumn
     */
    override fun totalItemsCount(): Int = scrollableState.layoutInfo.totalItemsCount
}

/**
 * Handles scroll with fling.
 *
 * @return An scroll with fling implementation of [RotaryHandler] which is suitable
 * for both low-res and high-res inputs.
 *
 * @param scrollableState Scrollable state which will be scrolled while receiving rotary events
 * @param flingBehavior Logic describing Fling behavior. If null - fling will not happen
 * @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb)
 * @param viewConfiguration [ViewConfiguration] for accessing default fling values
 */
private fun flingHandler(
    scrollableState: ScrollableState,
    rotaryHaptics: RotaryHapticHandler,
    flingBehavior: FlingBehavior? = null,
    isLowRes: Boolean,
    viewConfiguration: ViewConfiguration
): RotaryHandler {

    fun rotaryFlingBehavior() = flingBehavior?.run {
        RotaryFlingBehavior(
            scrollableState,
            flingBehavior,
            viewConfiguration,
            flingTimeframe = if (isLowRes) RotaryDefaults.lowResFlingTimeframe
            else RotaryDefaults.highResFlingTimeframe
        )
    }

    fun scrollBehavior() = RotaryScrollBehavior(scrollableState)

    return RotaryScrollHandler(
        isLowRes,
        rotaryHaptics,
        rotaryFlingBehaviorFactory = { rotaryFlingBehavior() },
        scrollBehaviorFactory = { scrollBehavior() }
    )
}

/**
 * Handles scroll with snap.
 *
 * @return A snap implementation of [RotaryHandler] which is either suitable for low-res or
 * high-res input.
 *
 * @param rotaryScrollAdapter Implementation of [RotaryScrollAdapter], which connects
 * scrollableState to a rotary input for snapping scroll actions.
 * @param rotaryHaptics Implementation of [RotaryHapticHandler] which handles haptics
 * for rotary usage
 * @param snapOffset An offset to be applied when snapping the item. After the snap the
 * snapped items offset will be [snapOffset].
 * @param maxThresholdDivider Factor to divide item size when calculating threshold.
 * @param scrollDistanceDivider A value which is used to slow down or
 * speed up the scroll before snap happens. The higher the value the slower the scroll.
 * @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb)
 */
private fun snapHandler(
    rotaryScrollAdapter: RotaryScrollAdapter,
    rotaryHaptics: RotaryHapticHandler,
    snapOffset: Int,
    maxThresholdDivider: Float,
    scrollDistanceDivider: Float,
    isLowRes: Boolean
): RotaryHandler {
    return if (isLowRes) {
        LowResRotarySnapHandler(
            rotaryHaptics = rotaryHaptics,
            snapBehaviourFactory = {
                RotarySnapHelper(
                    rotaryScrollAdapter,
                    snapOffset,
                )
            }
        )
    } else {
        HighResRotarySnapHandler(
            rotaryHaptics = rotaryHaptics,
            scrollDistanceDivider = scrollDistanceDivider,
            thresholdBehaviorFactory = {
                ThresholdBehavior(
                    maxThresholdDivider,
                    averageItemSize = { rotaryScrollAdapter.averageItemSize() }
                )
            },
            snapBehaviorFactory = {
                RotarySnapHelper(
                    rotaryScrollAdapter,
                    snapOffset,
                )
            },
            scrollBehaviorFactory = {
                RotaryScrollBehavior(rotaryScrollAdapter.scrollableState)
            }
        )
    }
}

/**
 * An abstract class for handling scroll events
 */
internal abstract class RotaryHandler {

    // Threshold for detection of a new gesture
    private val gestureThresholdTime = 200L
    protected var previousScrollEventTime = -1L

    /**
     * Handles scrolling events
     * @param coroutineScope A scope for performing async actions
     * @param event A scrollable event from rotary input, containing scrollable delta and timestamp
     */
    abstract suspend fun handleScrollEvent(
        coroutineScope: CoroutineScope,
        event: UnifiedRotaryEvent,
    )

    protected fun isNewScrollEvent(timestamp: Long): Boolean {
        val timeDelta = timestamp - previousScrollEventTime
        return previousScrollEventTime == -1L || timeDelta > gestureThresholdTime
    }
}

/**
 * A rotary event object which contains all necessary information for handling rotary
 * event with haptics.
 */
internal data class UnifiedRotaryEvent(
    val timestamp: Long,
    val deviceId: Int,
    val orientation: Orientation,
    val deltaInPixels: Float
)

/**
 * This class does a smooth animation when the scroll by N pixels is done.
 * This animation works well on Rsb(high-res) and Bezel(low-res) devices.
 */
internal class RotaryScrollBehavior(
    private val scrollableState: ScrollableState
) {
    private var sequentialAnimation = false
    private var scrollAnimation = AnimationState(0f)
    private var prevPosition = 0f
    private var scrollJob: Job = CompletableDeferred<Unit>()

    /**
     * Produces scroll to [targetValue]
     */
    fun scrollToTarget(coroutineScope: CoroutineScope, targetValue: Float) {
        cancelScrollIfActive()

        scrollJob = coroutineScope.async {
            scrollTo(targetValue)
        }
    }

    fun cancelScrollIfActive() {
        if (scrollJob.isActive) scrollJob.cancel()
    }

    private suspend fun scrollTo(targetValue: Float) {
        scrollableState.scroll(MutatePriority.UserInput) {
            debugLog { "ScrollAnimation value before start: ${scrollAnimation.value}" }

            scrollAnimation.animateTo(
                targetValue,
                animationSpec = spring(),
                sequentialAnimation = sequentialAnimation
            ) {
                val delta = value - prevPosition
                debugLog { "Animated by $delta, value: $value" }
                scrollBy(delta)
                prevPosition = value
                sequentialAnimation = value != this.targetValue
            }
        }
    }
}

/**
 * A helper class for snapping with rotary.
 */
internal class RotarySnapHelper(
    private val rotaryScrollAdapter: RotaryScrollAdapter,
    private val snapOffset: Int,
) {
    private var snapTarget: Int = rotaryScrollAdapter.currentItemIndex()
    private var sequentialSnap: Boolean = false

    private var anim = AnimationState(0f)
    private var expectedDistance = 0f

    private val defaultStiffness = 200f
    private var snapTargetUpdated = true

    /**
     * Updating snapping target. This method should be called before [snapToTargetItem].
     *
     * Snapping is done for current + [moveForElements] items.
     *
     * If [sequentialSnap] is true, items are summed up together.
     * For example, if [updateSnapTarget] is called with
     * [moveForElements] = 2, 3, 5 -> then the snapping will happen to current + 10 items
     *
     * If [sequentialSnap] is false, then [moveForElements] are not summed up together.
     */
    fun updateSnapTarget(moveForElements: Int, sequentialSnap: Boolean) {
        this.sequentialSnap = sequentialSnap
        if (sequentialSnap) {
            snapTarget += moveForElements
        } else {
            snapTarget = rotaryScrollAdapter.currentItemIndex() + moveForElements
        }
        snapTargetUpdated = true
        snapTarget = snapTarget.coerceIn(0 until rotaryScrollAdapter.totalItemsCount())
    }

    /**
     * Performs snapping to the closest item.
     */
    suspend fun snapToClosestItem() {
        // Perform the snapping animation
        rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) {
            debugLog { "snap to the closest item" }
            var prevPosition = 0f

            // Create and execute the snap animation
            AnimationState(0f).animateTo(
                targetValue = -rotaryScrollAdapter.currentItemOffset(),
                animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing)
            ) {
                val animDelta = value - prevPosition
                scrollBy(animDelta)
                prevPosition = value
            }
            // Update the snap target to ensure consistency
            snapTarget = rotaryScrollAdapter.currentItemIndex()
        }
    }

    /**
     * Returns true if top edge was reached
     */
    fun topEdgeReached(): Boolean = snapTarget <= 0

    /**
     * Returns true if bottom edge was reached
     */
    fun bottomEdgeReached(): Boolean =
        snapTarget >= rotaryScrollAdapter.totalItemsCount() - 1

    /**
     * Performs snapping to the specified in [updateSnapTarget] element
     */
    suspend fun snapToTargetItem() {
        if (!sequentialSnap) anim = AnimationState(0f)

        rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) {
            // If snapTargetUpdated is true -means the target was updated so we
            // need to do snap animation again
            while (snapTargetUpdated) {
                snapTargetUpdated = false
                var latestCenterItem: Int
                var continueFirstScroll = true
                debugLog { "snapTarget $snapTarget" }

                // First part of animation. Performing it until the target element centered.
                while (continueFirstScroll) {
                    latestCenterItem = rotaryScrollAdapter.currentItemIndex()
                    expectedDistance = expectedDistanceTo(snapTarget, snapOffset)
                    debugLog {
                        "expectedDistance = $expectedDistance, " +
                            "scrollableState.centerItemScrollOffset " +
                            "${rotaryScrollAdapter.currentItemOffset()}"
                    }

                    continueFirstScroll = false
                    var prevPosition = anim.value
                    anim.animateTo(
                        prevPosition + expectedDistance,
                        animationSpec = spring(
                            stiffness = defaultStiffness,
                            visibilityThreshold = 0.1f
                        ),
                        sequentialAnimation = (anim.velocity != 0f)
                    ) {
                        // Exit animation if snap target was updated
                        if (snapTargetUpdated) cancelAnimation()

                        val animDelta = value - prevPosition
                        debugLog {
                            "First animation, value:$value, velocity:$velocity, " +
                                "animDelta:$animDelta"
                        }
                        scrollBy(animDelta)
                        prevPosition = value

                        if (latestCenterItem != rotaryScrollAdapter.currentItemIndex()) {
                            continueFirstScroll = true
                            cancelAnimation()
                            return@animateTo
                        }

                        debugLog { "centerItemIndex =  ${rotaryScrollAdapter.currentItemIndex()}" }
                        if (rotaryScrollAdapter.currentItemIndex() == snapTarget) {
                            debugLog { "Target is near the centre. Cancelling first animation" }
                            debugLog {
                                "scrollableState.centerItemScrollOffset " +
                                    "${rotaryScrollAdapter.currentItemOffset()}"
                            }
                            expectedDistance = -rotaryScrollAdapter.currentItemOffset()
                            continueFirstScroll = false
                            cancelAnimation()
                            return@animateTo
                        }
                    }
                }
                // Exit animation if snap target was updated
                if (snapTargetUpdated) continue

                // Second part of Animation - animating to the centre of target element.
                var prevPosition = anim.value
                anim.animateTo(
                    prevPosition + expectedDistance,
                    animationSpec = SpringSpec(
                        stiffness = defaultStiffness,
                        visibilityThreshold = 0.1f
                    ),
                    sequentialAnimation = (anim.velocity != 0f)
                ) {
                    // Exit animation if snap target was updated
                    if (snapTargetUpdated) cancelAnimation()

                    val animDelta = value - prevPosition
                    debugLog { "Final animation. velocity:$velocity, animDelta:$animDelta" }
                    scrollBy(animDelta)
                    prevPosition = value
                }
            }
        }
    }

    private fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float {
        val averageSize = rotaryScrollAdapter.averageItemSize()
        val indexesDiff = index - rotaryScrollAdapter.currentItemIndex()
        debugLog { "Average size $averageSize" }
        return (averageSize * indexesDiff) +
            targetScrollOffset - rotaryScrollAdapter.currentItemOffset()
    }
}

/**
 * A modifier which handles rotary events.
 * It accepts ScrollHandler as the input - a class that handles the main scroll logic.
 */
internal fun Modifier.rotaryHandler(
    rotaryScrollHandler: RotaryHandler,
    reverseDirection: Boolean,
    inspectorInfo: InspectorInfo.() -> Unit = debugInspectorInfo {
        name = "rotaryHandler"
        properties["rotaryScrollHandler"] = rotaryScrollHandler
        properties["reverseDirection"] = reverseDirection
    }
): Modifier = this then RotaryHandlerElement(
    rotaryScrollHandler,
    reverseDirection,
    inspectorInfo
)

/**
 * Class responsible for Fling behaviour with rotary.
 * It tracks rotary events and produces fling when necessary.
 * @param flingTimeframe represents a time interval (in milliseconds) used to determine
 * whether a rotary input should trigger a fling. If no new events come during this interval,
 * then the fling is triggered.
 */
internal class RotaryFlingBehavior(
    private val scrollableState: ScrollableState,
    private val flingBehavior: FlingBehavior,
    viewConfiguration: ViewConfiguration,
    private val flingTimeframe: Long
) {
    private var flingJob: Job = CompletableDeferred<Unit>()

    // A time range during which the fling is valid.
    // For simplicity it's twice as long as [flingTimeframe]
    private val timeRangeToFling = flingTimeframe * 2

    //  A default fling factor for making fling slower
    private val flingScaleFactor = 0.7f

    private var previousVelocity = 0f

    private val rotaryVelocityTracker = RotaryVelocityTracker()

    private val minFlingSpeed = viewConfiguration.scaledMinimumFlingVelocity.toFloat()
    private val maxFlingSpeed = viewConfiguration.scaledMaximumFlingVelocity.toFloat()
    private var latestEventTimestamp: Long = 0

    private var flingVelocity: Float = 0f
    private var flingTimestamp: Long = 0

    /**
     * Starts a new fling tracking session
     * with specified timestamp
     */
    fun startFlingTracking(timestamp: Long) {
        rotaryVelocityTracker.start(timestamp)
        latestEventTimestamp = timestamp
        previousVelocity = 0f
    }

    fun cancelFlingIfActive() {
        if (flingJob.isActive) flingJob.cancel()
    }

    /**
     * Observing new event within a fling tracking session with new timestamp and delta
     */
    fun observeEvent(timestamp: Long, delta: Float) {
        rotaryVelocityTracker.move(timestamp, delta)
        latestEventTimestamp = timestamp
    }

    fun performFlingIfRequired(
        coroutineScope: CoroutineScope,
        beforeFling: () -> Unit,
        edgeReached: (velocity: Float) -> Unit
    ) {
        cancelFlingIfActive()

        flingJob = coroutineScope.async {
            trackFling(beforeFling, edgeReached)
        }
    }

    /**
     * Performing fling if necessary and calling [beforeFling] lambda before it is triggered.
     * [edgeReached] is called when the scroll reaches the end of the list and can't scroll further
     */
    private suspend fun trackFling(
        beforeFling: () -> Unit,
        edgeReached: (velocity: Float) -> Unit
    ) {
        val currentVelocity = rotaryVelocityTracker.velocity
        debugLog { "currentVelocity: $currentVelocity" }

        if (abs(currentVelocity) >= abs(previousVelocity)) {
            flingTimestamp = latestEventTimestamp
            flingVelocity = currentVelocity * flingScaleFactor
        }
        previousVelocity = currentVelocity

        // Waiting for a fixed amount of time before checking the fling
        delay(flingTimeframe)

        // For making a fling 2 criteria should be met:
        // 1) no more than
        // `timeRangeToFling` ms should pass between last fling detection
        // and the time of last motion event
        // 2) flingVelocity should exceed the minFlingSpeed
        debugLog {
            "Check fling:  flingVelocity: $flingVelocity " +
                "minFlingSpeed: $minFlingSpeed, maxFlingSpeed: $maxFlingSpeed"
        }
        if (latestEventTimestamp - flingTimestamp < timeRangeToFling &&
            abs(flingVelocity) > minFlingSpeed
        ) {
            // Call beforeFling because a fling will be performed
            beforeFling()
            val velocity = flingVelocity.coerceIn(-maxFlingSpeed, maxFlingSpeed)
            scrollableState.scroll(MutatePriority.UserInput) {
                with(flingBehavior) {
                    debugLog { "Flinging with velocity $velocity" }
                    val remainedVelocity = performFling(velocity)
                    debugLog { "-- Velocity after fling: $remainedVelocity" }
                    if (remainedVelocity != 0.0f) {
                        edgeReached(remainedVelocity)
                    }
                }
            }
        }
    }
}

/**
 * A scroll handler for scrolling without snapping and with or without fling.
 * A list is scrolled by the number of pixels received from the rotary device.
 *
 * For a high-res input it has a filtering for events which are coming
 * with an opposite sign (this might happen to devices with rsb,
 * especially at the end of the scroll )
 *
 * This scroll handler supports fling. It can be set with [RotaryFlingBehavior].
 */
internal class RotaryScrollHandler(
    private val isLowRes: Boolean,
    private val rotaryHaptics: RotaryHapticHandler,
    private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?,
    private val scrollBehaviorFactory: () -> RotaryScrollBehavior,
) : RotaryHandler() {
    private var rotaryScrollDistance = 0f

    private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory()
    private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory()

    override suspend fun handleScrollEvent(
        coroutineScope: CoroutineScope,
        event: UnifiedRotaryEvent,
    ) {
        val time = event.timestamp
        debugLog { "RotaryScrollHandler: handleScrollEvent" }

        if (isNewScrollEvent(time)) {
            debugLog { "New scroll event" }
            resetScrolling()
            resetFlingTracking(time)
        } else {
            // Due to the physics of high-res Rotary side button, some events might come
            // with an opposite axis value - either at the start or at the end of the motion.
            // We don't want to use these values for fling calculations.
            if (isLowRes || !isOppositeValueAfterScroll(event.deltaInPixels)) {
                rotaryFlingBehavior?.observeEvent(time, event.deltaInPixels)
            } else {
                debugLog { "Opposite value after scroll :${event.deltaInPixels}" }
            }
        }

        rotaryScrollDistance += event.deltaInPixels
        debugLog { "Rotary scroll distance: $rotaryScrollDistance" }

        rotaryHaptics.handleScrollHaptic(event)

        previousScrollEventTime = time
        scrollBehavior.scrollToTarget(coroutineScope, rotaryScrollDistance)

        rotaryFlingBehavior?.performFlingIfRequired(
            coroutineScope,
            beforeFling = {
                debugLog { "Calling beforeFling section" }
                resetScrolling()
            },
            edgeReached = { velocity ->
                rotaryHaptics.handleLimitHaptic(event, velocity > 0f)
            }
        )
    }

    private fun resetScrolling() {
        scrollBehavior.cancelScrollIfActive()
        scrollBehavior = scrollBehaviorFactory()
        rotaryScrollDistance = 0f
    }

    private fun resetFlingTracking(timestamp: Long) {
        rotaryFlingBehavior?.cancelFlingIfActive()
        rotaryFlingBehavior = rotaryFlingBehaviorFactory()
        rotaryFlingBehavior?.startFlingTracking(timestamp)
    }

    private fun isOppositeValueAfterScroll(delta: Float): Boolean =
        rotaryScrollDistance * delta < 0f &&
            (abs(delta) < abs(rotaryScrollDistance))
}

/**
 * A scroll handler for RSB(high-res) input with snapping and without fling.
 *
 * Threshold for snapping is set dynamically in ThresholdBehavior, which depends
 * on the scroll speed and the average size of the items.
 *
 * This scroll handler doesn't support fling.
 */
internal class HighResRotarySnapHandler(
    private val rotaryHaptics: RotaryHapticHandler,
    private val scrollDistanceDivider: Float,
    private val thresholdBehaviorFactory: () -> ThresholdBehavior,
    private val snapBehaviorFactory: () -> RotarySnapHelper,
    private val scrollBehaviorFactory: () -> RotaryScrollBehavior
) : RotaryHandler() {
    private val snapDelay = 100L

    // This parameter limits number of snaps which can happen during single event.
    private val maxSnapsPerEvent = 2

    private var snapJob: Job = CompletableDeferred<Unit>()

    private var accumulatedSnapDelta = 0f
    private var rotaryScrollDistance = 0f

    private var snapBehaviour = snapBehaviorFactory()
    private var scrollBehavior = scrollBehaviorFactory()
    private var thresholdBehavior = thresholdBehaviorFactory()

    private val scrollProximityEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.5f, 1.0f)

    override suspend fun handleScrollEvent(
        coroutineScope: CoroutineScope,
        event: UnifiedRotaryEvent,
    ) {
        val time = event.timestamp
        debugLog { "HighResSnapHandler: handleScrollEvent" }

        if (isNewScrollEvent(time)) {
            debugLog { "New scroll event" }
            resetScrolling()
            resetSnapping()
            resetThresholdTracking(time)
        }

        if (!isOppositeValueAfterScroll(event.deltaInPixels)) {
            thresholdBehavior.updateTracking(time, event.deltaInPixels)
        } else {
            debugLog { "Opposite value after scroll :${event.deltaInPixels}" }
        }

        val snapThreshold = thresholdBehavior.calculateSnapThreshold()
        debugLog { "snapThreshold: $snapThreshold" }

        if (!snapJob.isActive) {
            val proximityFactor = calculateProximityFactor(snapThreshold)
            rotaryScrollDistance += event.deltaInPixels * proximityFactor
        }
        debugLog { "Rotary scroll distance: $rotaryScrollDistance" }

        accumulatedSnapDelta += event.deltaInPixels
        debugLog { "Accumulated snap delta: $accumulatedSnapDelta" }

        previousScrollEventTime = time

        if (abs(accumulatedSnapDelta) > snapThreshold) {
            resetScrolling()

            // We limit a number of handled snap items per event to [maxSnapsPerEvent],
            // as otherwise the snap might happen too quickly
            val snapDistanceInItems = (accumulatedSnapDelta / snapThreshold).toInt()
                .coerceIn(-maxSnapsPerEvent..maxSnapsPerEvent)
            accumulatedSnapDelta -= snapThreshold * snapDistanceInItems
            //
            val sequentialSnap = snapJob.isActive

            debugLog {
                "Snap threshold reached: snapDistanceInItems:$snapDistanceInItems, " +
                    "sequentialSnap: $sequentialSnap, " +
                    "Accumulated snap delta: $accumulatedSnapDelta"
            }
            if (edgeNotReached(snapDistanceInItems)) {
                rotaryHaptics.handleSnapHaptic(event)
            }

            snapBehaviour.updateSnapTarget(snapDistanceInItems, sequentialSnap)
            if (!snapJob.isActive) {
                snapJob.cancel()
                snapJob = coroutineScope.async {
                    debugLog { "Snap started" }
                    try {
                        snapBehaviour.snapToTargetItem()
                    } finally {
                        debugLog { "Snap called finally" }
                    }
                }
            }
            rotaryScrollDistance = 0f
        } else {
            if (!snapJob.isActive) {
                val distanceWithDivider = rotaryScrollDistance / scrollDistanceDivider
                debugLog { "Scrolling for $distanceWithDivider px" }

                scrollBehavior.scrollToTarget(coroutineScope, distanceWithDivider)
                delay(snapDelay)

                resetScrolling()
                accumulatedSnapDelta = 0f
                snapBehaviour.updateSnapTarget(0, false)

                snapJob.cancel()
                snapJob = coroutineScope.async {
                    snapBehaviour.snapToClosestItem()
                }
            }
        }
    }

    /**
     * Calculates a value based on the rotaryScrollDistance and size of snapThreshold.
     * The closer rotaryScrollDistance to snapThreshold, the lower the value.
     */
    private fun calculateProximityFactor(snapThreshold: Float): Float =
        1 - scrollProximityEasing
            .transform(rotaryScrollDistance.absoluteValue / snapThreshold)

    private fun edgeNotReached(snapDistanceInItems: Int): Boolean =
        (!snapBehaviour.topEdgeReached() && snapDistanceInItems < 0) ||
            (!snapBehaviour.bottomEdgeReached() && snapDistanceInItems > 0)

    private fun resetScrolling() {
        scrollBehavior.cancelScrollIfActive()
        scrollBehavior = scrollBehaviorFactory()
        rotaryScrollDistance = 0f
    }

    private fun resetSnapping() {
        snapJob.cancel()
        snapBehaviour = snapBehaviorFactory()
        accumulatedSnapDelta = 0f
    }

    private fun resetThresholdTracking(time: Long) {
        thresholdBehavior = thresholdBehaviorFactory()
        thresholdBehavior.startThresholdTracking(time)
    }

    private fun isOppositeValueAfterScroll(delta: Float): Boolean =
        rotaryScrollDistance * delta < 0f &&
            (abs(delta) < abs(rotaryScrollDistance))
}

/**
 * A scroll handler for Bezel(low-res) input with snapping and without fling
 *
 * This scroll handler doesn't support fling.
 */
internal class LowResRotarySnapHandler(
    private val rotaryHaptics: RotaryHapticHandler,
    private val snapBehaviourFactory: () -> RotarySnapHelper
) : RotaryHandler() {

    private var snapJob: Job = CompletableDeferred<Unit>()

    private var accumulatedSnapDelta = 0f

    private var snapBehaviour = snapBehaviourFactory()

    override suspend fun handleScrollEvent(
        coroutineScope: CoroutineScope,
        event: UnifiedRotaryEvent,
    ) {
        val time = event.timestamp
        debugLog { "LowResSnapHandler: handleScrollEvent" }

        if (isNewScrollEvent(time)) {
            debugLog { "New scroll event" }
            resetSnapping()
        }

        accumulatedSnapDelta += event.deltaInPixels

        debugLog { "Accumulated snap delta: $accumulatedSnapDelta" }

        previousScrollEventTime = time

        if (abs(accumulatedSnapDelta) > 1f) {

            val snapDistanceInItems = sign(accumulatedSnapDelta).toInt()
            rotaryHaptics.handleSnapHaptic(event)
            val sequentialSnap = snapJob.isActive
            debugLog {
                "Snap threshold reached: snapDistanceInItems:$snapDistanceInItems, " +
                    "sequentialSnap: $sequentialSnap, " +
                    "Accumulated snap delta: $accumulatedSnapDelta"
            }

            snapBehaviour.updateSnapTarget(snapDistanceInItems, sequentialSnap)
            if (!snapJob.isActive) {
                snapJob.cancel()
                snapJob = coroutineScope.async {
                    debugLog { "Snap started" }
                    try {
                        snapBehaviour.snapToTargetItem()
                    } finally {
                        debugLog { "Snap called finally" }
                    }
                }
            }
            accumulatedSnapDelta = 0f
        }
    }

    private fun resetSnapping() {
        snapJob.cancel()
        snapBehaviour = snapBehaviourFactory()
        accumulatedSnapDelta = 0f
    }
}

/**
 *  This class is responsible for determining the dynamic 'snapping' threshold.
 *  The threshold dictates how much rotary input is required to trigger a snapping action.
 *
 *  The threshold is calculated dynamically based on the user's scroll input velocity.
 *  Faster scrolling results in a lower threshold, making snapping easier to achieve.
 *  An exponential smoothing is also applied to the velocity readings to reduce noise
 *  and provide more consistent threshold calculations.
 */
internal class ThresholdBehavior(
    // Factor to divide item size when calculating threshold.
    // Depending on the speed, it dynamically varies in range 1..maxThresholdDivider
    private val maxThresholdDivider: Float,
    // Min velocity for threshold calculation
    private val minVelocity: Float = 300f,
    // Max velocity for threshold calculation
    private val maxVelocity: Float = 3000f,
    // Smoothing factor for velocity readings
    private val smoothingConstant: Float = 0.4f,
    private val averageItemSize: () -> Float
    ) {
    private val thresholdDividerEasing: Easing = CubicBezierEasing(0.5f, 0.0f, 0.5f, 1.0f)

    private val rotaryVelocityTracker = RotaryVelocityTracker()

    private var smoothedVelocity = 0f

    /**
     *  Resets tracking state in preparation for a new scroll event.
     *  Initiates the velocity tracker and resets smoothed velocity.
     */
    fun startThresholdTracking(time: Long) {
        rotaryVelocityTracker.start(time)
        smoothedVelocity = 0f
    }

    /**
     * Updates the velocity tracker with the latest rotary input data.
     */
    fun updateTracking(timestamp: Long, delta: Float) {
        rotaryVelocityTracker.move(timestamp, delta)
        applySmoothing()
    }

    /**
     * Calculates the dynamic snapping threshold based on the current smoothed velocity.
     *
     * @return The threshold, in pixels, required to trigger a snapping action.
     */
    fun calculateSnapThreshold(): Float {
        // Calculate a divider fraction based on the smoothedVelocity within the defined range.
        val thresholdDividerFraction =
            thresholdDividerEasing.transform(
                inverseLerp(
                    minVelocity,
                    maxVelocity,
                    smoothedVelocity
                )
            )
        // Calculate the final threshold size by dividing the average item size by a dynamically
        // adjusted threshold divider.
        return averageItemSize() / lerp(
            1f,
            maxThresholdDivider,
            thresholdDividerFraction
        )
    }

    /**
     *  Applies exponential smoothing to the tracked velocity to reduce noise
     *  and provide more consistent threshold calculations.
     */
    private fun applySmoothing() {
        if (rotaryVelocityTracker.velocity != 0.0f) {
            // smooth the velocity
            smoothedVelocity = exponentialSmoothing(
                currentVelocity = rotaryVelocityTracker.velocity.absoluteValue,
                prevVelocity = smoothedVelocity,
                smoothingConstant = smoothingConstant
            )
        }
        debugLog { "rotaryVelocityTracker velocity: ${rotaryVelocityTracker.velocity}" }
        debugLog { "SmoothedVelocity: $smoothedVelocity" }
    }

    private fun exponentialSmoothing(
        currentVelocity: Float,
        prevVelocity: Float,
        smoothingConstant: Float
    ): Float =
        smoothingConstant * currentVelocity + (1 - smoothingConstant) * prevVelocity
}

private data class RotaryHandlerElement(
    private val rotaryScrollHandler: RotaryHandler,
    private val reverseDirection: Boolean,
    private val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<RotaryInputNode>() {
    override fun create(): RotaryInputNode = RotaryInputNode(
        rotaryScrollHandler,
        reverseDirection,
    )

    override fun update(node: RotaryInputNode) {
        debugLog { "Update launched!" }
        node.rotaryScrollHandler = rotaryScrollHandler
        node.reverseDirection = reverseDirection
    }

    override fun InspectorInfo.inspectableProperties() {
        inspectorInfo()
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || this::class != other::class) return false

        other as RotaryHandlerElement

        if (rotaryScrollHandler != other.rotaryScrollHandler) return false
        if (reverseDirection != other.reverseDirection) return false

        return true
    }

    override fun hashCode(): Int {
        var result = rotaryScrollHandler.hashCode()
        result = 31 * result + reverseDirection.hashCode()
        return result
    }
}

private class RotaryInputNode(
    var rotaryScrollHandler: RotaryHandler,
    var reverseDirection: Boolean,
) : RotaryInputModifierNode, Modifier.Node() {

    val channel = Channel<UnifiedRotaryEvent>(capacity = Channel.CONFLATED)
    val flow = channel.receiveAsFlow()

    override fun onAttach() {
        coroutineScope.launch {
            flow
                .collectLatest {
                    debugLog {
                        "Scroll event received: " +
                            "delta:${it.deltaInPixels}, timestamp:${it.timestamp}"
                    }
                    rotaryScrollHandler.handleScrollEvent(this, it)
                }
        }
    }

    override fun onRotaryScrollEvent(event: RotaryScrollEvent): Boolean = false

    override fun onPreRotaryScrollEvent(event: RotaryScrollEvent): Boolean {
        debugLog { "onPreRotaryScrollEvent" }

        val (orientation: Orientation, deltaInPixels: Float) =
            if (event.verticalScrollPixels != 0.0f)
                Pair(Orientation.Vertical, event.verticalScrollPixels)
            else
                Pair(Orientation.Horizontal, event.horizontalScrollPixels)

        channel.trySend(
            UnifiedRotaryEvent(
                timestamp = event.uptimeMillis,
                deviceId = event.inputDeviceId,
                orientation = orientation,
                deltaInPixels = deltaInPixels * if (reverseDirection) -1f else 1f
            )
        )
        return true
    }
}

/**
 * Debug logging that can be enabled.
 */
private const val DEBUG = false

private inline fun debugLog(generateMsg: () -> String) {
    if (DEBUG) {
        println("RotaryScroll: ${generateMsg()}")
    }
}