RuneGridBackground.kt

4.6 KB · raw

1package com.peachpasswords.ui.components23import androidx.compose.animation.core.LinearEasing4import androidx.compose.animation.core.animateFloat5import androidx.compose.animation.core.infiniteRepeatable6import androidx.compose.animation.core.rememberInfiniteTransition7import androidx.compose.animation.core.tween8import androidx.compose.foundation.Canvas9import androidx.compose.runtime.Composable10import androidx.compose.runtime.getValue11import androidx.compose.runtime.remember12import androidx.compose.ui.Modifier13import androidx.compose.ui.geometry.Offset14import androidx.compose.ui.graphics.Color15import androidx.compose.ui.text.TextStyle16import androidx.compose.ui.text.drawText17import androidx.compose.ui.text.rememberTextMeasurer18import androidx.compose.ui.unit.Dp19import androidx.compose.ui.unit.dp20import androidx.compose.ui.unit.sp21import com.peachpasswords.ui.theme.LocalReduceMotion22import com.peachpasswords.ui.theme.PeachType23import com.peachpasswords.ui.theme.peachColors24import kotlin.math.ceil2526private const val RUNE_COUNT = 25627private const val PATTERN_COLUMNS = 162829/**30 * Continuous PeachScript rune grid for onboarding only. This is deliberately31 * separate from LockRuneString, which is a static lock-screen accent row.32 *33 * Single layer at exactly 0.07 opacity — the real value from the browser's34 * `<RuneBackground opacity={0.07} />` (RuneBackground.tsx), not the ~8-15%35 * range a prior pass guessed. The browser has no second/parallax layer;36 * matching that means not adding one here either (ONBOARDING_ADDENDUM.md §2).37 */38@Composable39fun RuneGridBackground(modifier: Modifier = Modifier) {40    val reduceMotion = LocalReduceMotion.current41    val layer = rememberRuneLayer(42        color = peachColors.T0.copy(alpha = 0.07f),43        fontSize = 24,44        tileWidth = 54.dp,45        rowHeight = 42.dp,46        speedDpPerSecond = 8f,47        seed = 19,48    )49    val offset = rememberRuneOffset(layer.patternWidthDp, layer.speedDpPerSecond, reduceMotion)5051    Canvas(modifier) {52        drawRuneLayer(layer, offset)53    }54}5556@Composable57private fun rememberRuneLayer(58    color: Color,59    fontSize: Int,60    tileWidth: Dp,61    rowHeight: Dp,62    speedDpPerSecond: Float,63    seed: Int,64): RuneLayer {65    val measurer = rememberTextMeasurer()66    val style = remember(color, fontSize) {67        TextStyle(fontFamily = PeachType.Script, fontSize = fontSize.sp, color = color)68    }69    val glyphs = remember(style, measurer) {70        List(RUNE_COUNT) { index ->71            measurer.measure((0xE000 + index).toChar().toString(), style)72        }73    }74    return RuneLayer(75        glyphs = glyphs,76        tileWidth = tileWidth,77        rowHeight = rowHeight,78        patternWidthDp = tileWidth * PATTERN_COLUMNS,79        speedDpPerSecond = speedDpPerSecond,80        seed = seed,81    )82}8384@Composable85private fun rememberRuneOffset(86    patternWidth: Dp,87    speedDpPerSecond: Float,88    reduceMotion: Boolean,89): Float {90    if (reduceMotion) return 0f91    val durationMillis = ((patternWidth.value / speedDpPerSecond) * 1000f).toInt().coerceAtLeast(1)92    val transition = rememberInfiniteTransition(label = "rune_grid")93    val offset by transition.animateFloat(94        initialValue = 0f,95        targetValue = patternWidth.value,96        animationSpec = infiniteRepeatable(tween(durationMillis, easing = LinearEasing)),97        label = "rune_grid_offset",98    )99    return offset100}101102private data class RuneLayer(103    val glyphs: List<androidx.compose.ui.text.TextLayoutResult>,104    val tileWidth: Dp,105    val rowHeight: Dp,106    val patternWidthDp: Dp,107    val speedDpPerSecond: Float,108    val seed: Int,109)110111private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneLayer(112    layer: RuneLayer,113    offsetDp: Float,114) {115    val tileWidth = layer.tileWidth.toPx()116    val rowHeight = layer.rowHeight.toPx()117    val patternWidth = layer.patternWidthDp.toPx()118    val offset = (offsetDp / layer.patternWidthDp.value) * patternWidth119    val columns = ceil(size.width / tileWidth).toInt() + PATTERN_COLUMNS * 2120    val rows = ceil(size.height / rowHeight).toInt() + 2121    val startColumn = -PATTERN_COLUMNS122123    for (row in -1 until rows) {124        val y = row * rowHeight125        for (i in 0..columns) {126            val column = startColumn + i127            val x = column * tileWidth - offset128            val patternColumn = positiveMod(column, PATTERN_COLUMNS)129            val glyphIndex = positiveMod(patternColumn * 31 + row * 17 + layer.seed, RUNE_COUNT)130            drawText(layer.glyphs[glyphIndex], topLeft = Offset(x, y))131        }132    }133}134135private fun positiveMod(value: Int, modulus: Int): Int = ((value % modulus) + modulus) % modulus