package com.peachpasswords.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.withFrameMillis
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.peachpasswords.ui.theme.LocalReduceMotion
import com.peachpasswords.ui.theme.PeachType
import com.peachpasswords.ui.theme.peachColors
import kotlin.math.ceil
private const val RUNE_COUNT = 256
private const val PATTERN_COLUMNS = 16
/**
* Continuous PeachScript rune grid for onboarding only. This is deliberately
* separate from LockRuneString, which is a static lock-screen accent row.
*
* Single layer at exactly 0.07 opacity — the real value from the browser's
* `` (RuneBackground.tsx), not the ~8-15%
* range a prior pass guessed. The browser has no second/parallax layer;
* matching that means not adding one here either (ONBOARDING_ADDENDUM.md §2).
*/
@Composable
fun RuneGridBackground(modifier: Modifier = Modifier) {
val reduceMotion = LocalReduceMotion.current
val layer = rememberRuneLayer(
color = peachColors.T0.copy(alpha = 0.07f),
fontSize = 24,
tileWidth = 54.dp,
rowHeight = 42.dp,
seed = 19,
)
// Driven off the frame clock, not animateFloat/infiniteRepeatable: those
// are scaled by the system animator duration setting, so an ambient
// background silently froze solid wherever that's turned down to 0 (the
// browser's requestAnimationFrame loop has no such dependency). Read in
// the draw scope so a frame is a redraw, not a recomposition.
val elapsedSeconds = remember { mutableFloatStateOf(0f) }
LaunchedEffect(reduceMotion) {
if (reduceMotion) return@LaunchedEffect
var last = withFrameMillis { it }
while (true) {
val now = withFrameMillis { it }
elapsedSeconds.floatValue += (now - last).coerceAtMost(64L) / 1000f
last = now
}
}
Canvas(modifier) {
drawRuneLayer(layer, elapsedSeconds.floatValue)
}
}
@Composable
private fun rememberRuneLayer(
color: Color,
fontSize: Int,
tileWidth: Dp,
rowHeight: Dp,
seed: Int,
): RuneLayer {
val measurer = rememberTextMeasurer()
val style = remember(color, fontSize) {
TextStyle(fontFamily = PeachType.Script, fontSize = fontSize.sp, color = color)
}
val glyphs = remember(style, measurer) {
List(RUNE_COUNT) { index ->
measurer.measure((0xE000 + index).toChar().toString(), style)
}
}
return RuneLayer(
glyphs = glyphs,
tileWidth = tileWidth,
rowHeight = rowHeight,
patternWidthDp = tileWidth * PATTERN_COLUMNS,
seed = seed,
)
}
private data class RuneLayer(
val glyphs: List,
val tileWidth: Dp,
val rowHeight: Dp,
val patternWidthDp: Dp,
val seed: Int,
)
/** Browser parity (RuneBackground.tsx SPEEDS): three drift rates, cycled by row. */
private val ROW_SPEEDS_DP = floatArrayOf(8f, 11f, 6f)
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneLayer(
layer: RuneLayer,
elapsedSeconds: Float,
) {
val tileWidth = layer.tileWidth.toPx()
val rowHeight = layer.rowHeight.toPx()
val patternWidth = layer.patternWidthDp.toPx()
val columns = ceil(size.width / tileWidth).toInt() + PATTERN_COLUMNS * 2
val rows = ceil(size.height / rowHeight).toInt() + 2
val startColumn = -PATTERN_COLUMNS
for (row in -1 until rows) {
val y = row * rowHeight
// Each row drifts at its own speed, so the field shears instead of
// sliding as one rigid block.
val speedPx = ROW_SPEEDS_DP[positiveMod(row, ROW_SPEEDS_DP.size)] * density
val offset = (elapsedSeconds * speedPx) % patternWidth
for (i in 0..columns) {
val column = startColumn + i
val x = column * tileWidth - offset
val patternColumn = positiveMod(column, PATTERN_COLUMNS)
val glyphIndex = positiveMod(patternColumn * 31 + row * 17 + layer.seed, RUNE_COUNT)
drawText(layer.glyphs[glyphIndex], topLeft = Offset(x, y))
}
}
}
private fun positiveMod(value: Int, modulus: Int): Int = ((value % modulus) + modulus) % modulus