Auto commit Thu Mar 19 10:01:02 PM CDT 2026

This commit is contained in:
Tretzi
2026-03-19 22:01:02 -05:00
parent ee60bf0c0f
commit 4b1da39888
5 changed files with 97 additions and 22 deletions

View File

@@ -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<ActiveCurve?>(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))
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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())
}
}