diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fa0e6e6..ae099e3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { // (android.media.midi) and AAudio low-latency audio we rely on. minSdk = 26 targetSdk = 34 - versionCode = 8 - versionName = "0.4.1" + versionCode = 9 + versionName = "0.4.2" // We provide our own instrumentation runner if/when tests are added. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt index bd0917f..bd0fcc3 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt @@ -5,11 +5,15 @@ package space.rcmd.android.sizzle.ui.tracker import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -80,20 +84,41 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) { val velPx = trackPx * 0.25f val visibleRows = (heightPx / rowPx).toInt().coerceAtLeast(1) + val maxScrollPx = (pattern.length * rowPx - heightPx).coerceAtLeast(0f) - // Which row sits at the top of the viewport. Computed on demand (not stored - // in composition) so it can be read from the draw phase during playback and - // from gestures at touch time, always against the live transport/cursor. - fun firstRow(): Int { + // Vertical scroll model. By default the view FOLLOWS — it auto-centres on the + // playhead while playing, else on the cursor. A finger flick detaches it into + // free inertial scrolling; switching to another block re-attaches. Both states + // are keyed on `pattern`, so selecting a different block re-inits them (== the + // "resume following on block switch" behaviour). + val following = remember(pattern) { mutableStateOf(true) } + val scrollPx = remember(pattern) { mutableStateOf(0f) } + + // Pixel scroll offset the view auto-follows (top of viewport, 0 == line 0). + fun autoScrollPx(): Float { val t = transportState.value val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine - return (focusLine - visibleRows / 2) - .coerceIn(0, (pattern.length - visibleRows).coerceAtLeast(0)) + return (focusLine * rowPx + rowPx / 2f - heightPx / 2f).coerceIn(0f, maxScrollPx) + } + // The offset actually shown: the followed one, or the free one once detached. + fun effScrollPx(): Float = + if (following.value) autoScrollPx() else scrollPx.value.coerceIn(0f, maxScrollPx) + + // Fling/drag scrolling. The first delta seeds the free position from wherever + // the followed view currently sits, then detaches following. + val scrollState = remember(pattern, rowPx, heightPx, maxScrollPx) { + ScrollableState { delta -> + val old = if (following.value) autoScrollPx() else scrollPx.value + following.value = false + val next = (old - delta).coerceIn(0f, maxScrollPx) + scrollPx.value = next + old - next // consumed (partial at the ends stops the fling) + } } fun hitTest(x: Float, y: Float): Triple? { if (x < gutterPx) return null - val line = (firstRow() + (y / rowPx).toInt()).coerceIn(0, pattern.length - 1) + val line = ((effScrollPx() + y) / rowPx).toInt().coerceIn(0, pattern.length - 1) val tx = x - gutterPx val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1) val within = tx - track * trackPx @@ -108,6 +133,8 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) { Canvas( Modifier .fillMaxSize() + // Inertial vertical scrolling (fling), detaching the auto-follow. + .scrollable(orientation = Orientation.Vertical, state = scrollState) .pointerInput(pattern, widthPx, heightPx) { // Tap moves the cursor to the cell/column. Note entry is done with // the punch-in keyboard or external controllers — there is no @@ -127,7 +154,11 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) { // scroll position. A change to either invalidates THIS DRAW only — no // recomposition, no relayout — which is what keeps the playhead smooth. val transport = transportState.value - val top = firstRow() + // Fractional scroll: the first visible line plus a sub-row pixel shift so + // flinging scrolls smoothly rather than snapping row-by-row. + val scroll = effScrollPx() + val firstLine = (scroll / rowPx).toInt() + val yShift = -(scroll - firstLine * rowPx) // Selection rectangle (draw-phase reads → redraw as the cursor drags it). val selActive = vm.hasSelection val selTracks = vm.selTrackRange @@ -142,11 +173,13 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) { drawRect(c.grid, Offset(x0, 0f), Size(1f, heightPx)) // separator line } - // 2) Rows: beat/bar/playhead backgrounds + cursor + text. - for (screen in 0 until visibleRows) { - val line = top + screen + // 2) Rows: beat/bar/playhead backgrounds + cursor + text. One extra row so + // the partially-scrolled bottom row is filled; y may start slightly above 0. + for (screen in 0..visibleRows) { + val line = firstLine + screen if (line >= pattern.length) break - val y = screen * rowPx + if (line < 0) continue + val y = yShift + screen * rowPx val rowColor = when { transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)