RuneGridBackground.kt

4.7 KB · raw

1package com.peachpasswords.ui.components23import androidx.compose.foundation.Canvas4import androidx.compose.runtime.Composable5import androidx.compose.runtime.LaunchedEffect6import androidx.compose.runtime.mutableFloatStateOf7import androidx.compose.runtime.withFrameMillis8import androidx.compose.runtime.remember9import androidx.compose.ui.Modifier10import androidx.compose.ui.geometry.Offset11import androidx.compose.ui.graphics.Color12import androidx.compose.ui.text.TextStyle13import androidx.compose.ui.text.drawText14import androidx.compose.ui.text.rememberTextMeasurer15import androidx.compose.ui.unit.Dp16import androidx.compose.ui.unit.dp17import androidx.compose.ui.unit.sp18import com.peachpasswords.ui.theme.LocalReduceMotion19import com.peachpasswords.ui.theme.PeachType20import com.peachpasswords.ui.theme.peachColors21import kotlin.math.ceil2223private const val RUNE_COUNT = 25624private const val PATTERN_COLUMNS = 162526/**27 * Continuous PeachScript rune grid for onboarding only. This is deliberately28 * separate from LockRuneString, which is a static lock-screen accent row.29 *30 * Single layer at exactly 0.07 opacity — the real value from the browser's31 * `<RuneBackground opacity={0.07} />` (RuneBackground.tsx), not the ~8-15%32 * range a prior pass guessed. The browser has no second/parallax layer;33 * matching that means not adding one here either (ONBOARDING_ADDENDUM.md §2).34 */35@Composable36fun RuneGridBackground(modifier: Modifier = Modifier) {37    val reduceMotion = LocalReduceMotion.current38    val layer = rememberRuneLayer(39        color = peachColors.T0.copy(alpha = 0.07f),40        fontSize = 24,41        tileWidth = 54.dp,42        rowHeight = 42.dp,43        seed = 19,44    )4546    // Driven off the frame clock, not animateFloat/infiniteRepeatable: those47    // are scaled by the system animator duration setting, so an ambient48    // background silently froze solid wherever that's turned down to 0 (the49    // browser's requestAnimationFrame loop has no such dependency). Read in50    // the draw scope so a frame is a redraw, not a recomposition.51    val elapsedSeconds = remember { mutableFloatStateOf(0f) }52    LaunchedEffect(reduceMotion) {53        if (reduceMotion) return@LaunchedEffect54        var last = withFrameMillis { it }55        while (true) {56            val now = withFrameMillis { it }57            elapsedSeconds.floatValue += (now - last).coerceAtMost(64L) / 1000f58            last = now59        }60    }6162    Canvas(modifier) {63        drawRuneLayer(layer, elapsedSeconds.floatValue)64    }65}6667@Composable68private fun rememberRuneLayer(69    color: Color,70    fontSize: Int,71    tileWidth: Dp,72    rowHeight: Dp,73    seed: Int,74): RuneLayer {75    val measurer = rememberTextMeasurer()76    val style = remember(color, fontSize) {77        TextStyle(fontFamily = PeachType.Script, fontSize = fontSize.sp, color = color)78    }79    val glyphs = remember(style, measurer) {80        List(RUNE_COUNT) { index ->81            measurer.measure((0xE000 + index).toChar().toString(), style)82        }83    }84    return RuneLayer(85        glyphs = glyphs,86        tileWidth = tileWidth,87        rowHeight = rowHeight,88        patternWidthDp = tileWidth * PATTERN_COLUMNS,89        seed = seed,90    )91}9293private data class RuneLayer(94    val glyphs: List<androidx.compose.ui.text.TextLayoutResult>,95    val tileWidth: Dp,96    val rowHeight: Dp,97    val patternWidthDp: Dp,98    val seed: Int,99)100101/** Browser parity (RuneBackground.tsx SPEEDS): three drift rates, cycled by row. */102private val ROW_SPEEDS_DP = floatArrayOf(8f, 11f, 6f)103104private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneLayer(105    layer: RuneLayer,106    elapsedSeconds: Float,107) {108    val tileWidth = layer.tileWidth.toPx()109    val rowHeight = layer.rowHeight.toPx()110    val patternWidth = layer.patternWidthDp.toPx()111    val columns = ceil(size.width / tileWidth).toInt() + PATTERN_COLUMNS * 2112    val rows = ceil(size.height / rowHeight).toInt() + 2113    val startColumn = -PATTERN_COLUMNS114115    for (row in -1 until rows) {116        val y = row * rowHeight117        // Each row drifts at its own speed, so the field shears instead of118        // sliding as one rigid block.119        val speedPx = ROW_SPEEDS_DP[positiveMod(row, ROW_SPEEDS_DP.size)] * density120        val offset = (elapsedSeconds * speedPx) % patternWidth121        for (i in 0..columns) {122            val column = startColumn + i123            val x = column * tileWidth - offset124            val patternColumn = positiveMod(column, PATTERN_COLUMNS)125            val glyphIndex = positiveMod(patternColumn * 31 + row * 17 + layer.seed, RUNE_COUNT)126            drawText(layer.glyphs[glyphIndex], topLeft = Offset(x, y))127        }128    }129}130131private fun positiveMod(value: Int, modulus: Int): Int = ((value % modulus) + modulus) % modulus