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 com.peachpasswords.ui.theme.LocalPeachAccent
import kotlin.math.PI
import kotlin.math.sin
import kotlin.random.Random
import kotlin.math.ceil
private const val RUNE_COUNT = 256
private const val PATTERN_COLUMNS = 16
private const val GLOW_COUNT = 8
/**
* 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,
alpha: Float = 0.07f,
// Browser parity: the panic screen's final step runs the same drifting
// field with a few runes flaring and dying at random (RuneCanvas's glows).
glowing: Boolean = false,
) {
val reduceMotion = LocalReduceMotion.current
val accent = LocalPeachAccent.current
val layer = rememberRuneLayer(
color = peachColors.T0.copy(alpha = alpha),
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) {
val t = elapsedSeconds.floatValue
drawRuneLayer(layer, t)
if (glowing) drawRuneGlows(layer, t, accent)
}
}
/** A few runes flare and fade at random positions — the panic screen's chaos. */
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneGlows(
layer: RuneLayer,
elapsedSeconds: Float,
accent: Color,
) {
val tileWidth = layer.tileWidth.toPx()
val rowHeight = layer.rowHeight.toPx()
val cols = ceil(size.width / tileWidth).toInt() + 1
val rows = ceil(size.height / rowHeight).toInt() + 1
val rng = Random(layer.seed)
repeat(GLOW_COUNT) {
// Each glow keeps a fixed cell and its own period/offset, so the field
// reads as random flares without tracking mutable per-glow state.
val col = rng.nextInt(cols.coerceAtLeast(1))
val row = rng.nextInt(rows.coerceAtLeast(1))
val period = 2f + rng.nextFloat() * 3f
val offset = rng.nextFloat() * period
val phase = ((elapsedSeconds + offset) % period) / period
val a = sin(phase * PI.toFloat()) * 0.85f
if (a <= 0.02f) return@repeat
val glyph = layer.glyphs[positiveMod(col * 31 + row * 17 + layer.seed, RUNE_COUNT)]
drawText(
glyph,
topLeft = Offset(col * tileWidth, row * rowHeight),
color = accent.copy(alpha = a),
)
}
}
@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