diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt index 2478c6e..e901433 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -168,6 +169,13 @@ private fun TimelineEditorContent( internal enum class Side { LEFT, RIGHT } +internal object TimelineCurveColors { + val Flash: Color = Color(0xFF00FF00) + val Blank: Color = Color(0xFFFF0000) + val Carrier: Color = Color(0xFF0000FF) + val Binaural: Color = Color(0xFFFFFF00) +} + private enum class HandleDrag { NONE, START, END } @Composable @@ -199,13 +207,13 @@ internal fun TimelineGraph( var draggingCurveId by remember { mutableStateOf(null) } val leftColor = when (leftId) { - ActiveCurve.VISUAL_LEFT -> Color(0xFF00C853) - ActiveCurve.AUDIO_LEFT -> Color(0xFF2196F3) + ActiveCurve.VISUAL_LEFT -> TimelineCurveColors.Flash + ActiveCurve.AUDIO_LEFT -> TimelineCurveColors.Carrier else -> Color.White } val rightColor = when (rightId) { - ActiveCurve.VISUAL_RIGHT -> Color(0xFFD50000) - ActiveCurve.AUDIO_RIGHT -> Color(0xFFFFEB3B) + ActiveCurve.VISUAL_RIGHT -> TimelineCurveColors.Blank + ActiveCurve.AUDIO_RIGHT -> TimelineCurveColors.Binaural else -> Color.White } @@ -226,8 +234,16 @@ internal fun TimelineGraph( } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(leftLabel, color = if (selectedLeft) leftColor else leftColor.copy(alpha = 0.65f)) - Text(rightLabel, color = if (selectedRight) rightColor else rightColor.copy(alpha = 0.65f)) + Text( + leftLabel, + color = if (selectedLeft) leftColor else leftColor.copy(alpha = 0.65f), + modifier = Modifier.clickable { onTapSideSelect(Side.LEFT) } + ) + Text( + rightLabel, + color = if (selectedRight) rightColor else rightColor.copy(alpha = 0.65f), + modifier = Modifier.clickable { onTapSideSelect(Side.RIGHT) } + ) } val onCurveEditedHaptic = { @@ -247,19 +263,40 @@ internal fun TimelineGraph( val startX = viewport.timeToX(selection.startSec, w) val endX = viewport.timeToX(selection.endSec, w) val hitPx = 32f - val selectedCurveId = if (activeCurve == rightId) rightId else leftId - val selectedCurve = if (selectedCurveId == leftId) curveLeft else curveRight - val hitPoint = selectedCurve.hitTestPoint(start, viewport, w, h) - if (hitPoint != null) { - draggingPointIndex = hitPoint - draggingCurveId = selectedCurveId + + val hits = buildList { + curveLeft.hitTestPoint(start, viewport, w, h)?.let { idx -> + val point = curveLeft.points[idx] + val x = viewport.timeToX(point.tSec, w) + val y = valueToY(point.value01, h) + val dx = start.x - x + val dy = start.y - y + add(Triple(leftId, idx, dx * dx + dy * dy)) + } + curveRight.hitTestPoint(start, viewport, w, h)?.let { idx -> + val point = curveRight.points[idx] + val x = viewport.timeToX(point.tSec, w) + val y = valueToY(point.value01, h) + val dx = start.x - x + val dy = start.y - y + add(Triple(rightId, idx, dx * dx + dy * dy)) + } + } + + val nearestHit = hits.minByOrNull { it.third } + if (nearestHit != null) { + draggingCurveId = nearestHit.first + draggingPointIndex = nearestHit.second dragMode = HandleDrag.NONE + onTapSideSelect(if (nearestHit.first == leftId) Side.LEFT else Side.RIGHT) } else { dragMode = when { abs(start.x - startX) <= hitPx -> HandleDrag.START abs(start.x - endX) <= hitPx -> HandleDrag.END else -> HandleDrag.NONE } + draggingPointIndex = null + draggingCurveId = null } }, onDragEnd = { @@ -273,19 +310,20 @@ internal fun TimelineGraph( dragMode = HandleDrag.NONE }, onDrag = { change, dragAmount -> + change.consume() val w = size.width.toFloat() val h = size.height.toFloat() val pointIndex = draggingPointIndex val curveId = draggingCurveId if (pointIndex != null && curveId != null) { val targetCurve = if (curveId == leftId) curveLeft else curveRight - val updated = targetCurve.movePointVertical(pointIndex, change.position.y + dragAmount.y, h) + val updated = targetCurve.movePointVertical(pointIndex, change.position.y, h) onUpdateCurve(curveId, updated) onCurveEditedHaptic() return@detectDragGestures } - val t = viewport.xToTimeSec(change.position.x + dragAmount.x, w).toInt().coerceAtLeast(0) + val t = viewport.xToTimeSec(change.position.x, w).toInt().coerceAtLeast(0) when (dragMode) { HandleDrag.START -> onSelectionChanged(TimelineSelection(startSec = min(t, selection.endSec - 60), endSec = selection.endSec)) HandleDrag.END -> onSelectionChanged(TimelineSelection(startSec = selection.startSec, endSec = max(t, selection.startSec + 60))) @@ -393,9 +431,10 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurve( pts.forEach { p -> val x = viewport.timeToX(p.tSec, size.width) - if (x < -12f || x > size.width + 12f) return@forEach + if (x < -18f || x > size.width + 18f) return@forEach val y = valueToY(p.value01, size.height) - drawCircle(color = color, radius = if (selected) 6.5f else 5f, center = Offset(x, y)) + drawCircle(color = color.copy(alpha = if (selected) 0.24f else 0.14f), radius = if (selected) 16f else 13f, center = Offset(x, y)) + drawCircle(color = color, radius = if (selected) 7.5f else 6f, center = Offset(x, y)) } } diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt index f775734..6250741 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt @@ -211,16 +211,16 @@ data class TimelineCurve( } fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? { - val radiusPx = 22f - points.forEachIndexed { idx, p -> + val radiusPx = 36f + return points.mapIndexedNotNull { idx, p -> val x = viewport.timeToX(p.tSec, widthPx) - if (x < -radiusPx || x > widthPx + radiusPx) return@forEachIndexed + if (x < -radiusPx || x > widthPx + radiusPx) return@mapIndexedNotNull null val y = (1f - p.value01) * heightPx val dx = offset.x - x val dy = offset.y - y - if (dx * dx + dy * dy <= radiusPx * radiusPx) return idx - } - return null + val dist2 = dx * dx + dy * dy + if (dist2 <= radiusPx * radiusPx) idx to dist2 else null + }.minByOrNull { it.second }?.first } fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec) diff --git a/app/src/test/java/com/mindmachine/mvp/DurationSliderRangeTest.kt b/app/src/test/java/com/mindmachine/mvp/DurationSliderRangeTest.kt new file mode 100644 index 0000000..4ab9b45 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/DurationSliderRangeTest.kt @@ -0,0 +1,18 @@ +package com.mindmachine.mvp + +import com.mindmachine.mvp.session.TimelineEditorState +import org.junit.Assert.assertEquals +import org.junit.Test + +class DurationSliderRangeTest { + @Test + fun timeline_duration_clamps_to_one_minute_and_eight_hours() { + assertEquals(60, TimelineEditorState.default(30).durationSec) + assertEquals(8 * 60 * 60, TimelineEditorState.default(9 * 60 * 60).durationSec) + } + + @Test + fun timeline_duration_snaps_to_whole_minutes() { + assertEquals(2 * 60, TimelineEditorState.default(91).durationSec) + } +} diff --git a/app/src/test/java/com/mindmachine/mvp/TimelineGraphConfigTest.kt b/app/src/test/java/com/mindmachine/mvp/TimelineGraphConfigTest.kt new file mode 100644 index 0000000..f9143a7 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/TimelineGraphConfigTest.kt @@ -0,0 +1,16 @@ +package com.mindmachine.mvp + +import androidx.compose.ui.graphics.toArgb +import com.mindmachine.mvp.session.TimelineCurveColors +import org.junit.Assert.assertEquals +import org.junit.Test + +class TimelineGraphConfigTest { + @Test + fun curve_colors_match_requested_mapping_exactly() { + assertEquals(0xFF00FF00.toInt(), TimelineCurveColors.Flash.toArgb()) + assertEquals(0xFFFF0000.toInt(), TimelineCurveColors.Blank.toArgb()) + assertEquals(0xFF0000FF.toInt(), TimelineCurveColors.Carrier.toArgb()) + assertEquals(0xFFFFFF00.toInt(), TimelineCurveColors.Binaural.toArgb()) + } +} diff --git a/gradle.properties b/gradle.properties index f0a2e55..6f59a39 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.daemon=false +kotlin.compiler.execution.strategy=in-process android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true