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