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 com.peachpasswords.ui.theme.LocalPeachAccent22import kotlin.math.PI23import kotlin.math.sin24import kotlin.random.Random25import kotlin.math.ceil2627private const val RUNE_COUNT = 25628private const val PATTERN_COLUMNS = 1629private const val GLOW_COUNT = 83031323334353637383940@Composable41fun RuneGridBackground(42 modifier: Modifier = Modifier,43 alpha: Float = 0.07f,44 45 46 glowing: Boolean = false,47) {48 val reduceMotion = LocalReduceMotion.current49 val accent = LocalPeachAccent.current50 val layer = rememberRuneLayer(51 color = peachColors.T0.copy(alpha = alpha),52 fontSize = 24,53 tileWidth = 54.dp,54 rowHeight = 42.dp,55 seed = 19,56 )5758 59 60 61 62 63 val elapsedSeconds = remember { mutableFloatStateOf(0f) }64 LaunchedEffect(reduceMotion) {65 if (reduceMotion) return@LaunchedEffect66 var last = withFrameMillis { it }67 while (true) {68 val now = withFrameMillis { it }69 elapsedSeconds.floatValue += (now - last).coerceAtMost(64L) / 1000f70 last = now71 }72 }7374 Canvas(modifier) {75 val t = elapsedSeconds.floatValue76 drawRuneLayer(layer, t)77 if (glowing) drawRuneGlows(layer, t, accent)78 }79}808182private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneGlows(83 layer: RuneLayer,84 elapsedSeconds: Float,85 accent: Color,86) {87 val tileWidth = layer.tileWidth.toPx()88 val rowHeight = layer.rowHeight.toPx()89 val cols = ceil(size.width / tileWidth).toInt() + 190 val rows = ceil(size.height / rowHeight).toInt() + 191 val rng = Random(layer.seed)92 repeat(GLOW_COUNT) {93 94 95 val col = rng.nextInt(cols.coerceAtLeast(1))96 val row = rng.nextInt(rows.coerceAtLeast(1))97 val period = 2f + rng.nextFloat() * 3f98 val offset = rng.nextFloat() * period99 val phase = ((elapsedSeconds + offset) % period) / period100 val a = sin(phase * PI.toFloat()) * 0.85f101 if (a <= 0.02f) return@repeat102 val glyph = layer.glyphs[positiveMod(col * 31 + row * 17 + layer.seed, RUNE_COUNT)]103 drawText(104 glyph,105 topLeft = Offset(col * tileWidth, row * rowHeight),106 color = accent.copy(alpha = a),107 )108 }109}110111@Composable112private fun rememberRuneLayer(113 color: Color,114 fontSize: Int,115 tileWidth: Dp,116 rowHeight: Dp,117 seed: Int,118): RuneLayer {119 val measurer = rememberTextMeasurer()120 val style = remember(color, fontSize) {121 TextStyle(fontFamily = PeachType.Script, fontSize = fontSize.sp, color = color)122 }123 val glyphs = remember(style, measurer) {124 List(RUNE_COUNT) { index ->125 measurer.measure((0xE000 + index).toChar().toString(), style)126 }127 }128 return RuneLayer(129 glyphs = glyphs,130 tileWidth = tileWidth,131 rowHeight = rowHeight,132 patternWidthDp = tileWidth * PATTERN_COLUMNS,133 seed = seed,134 )135}136137private data class RuneLayer(138 val glyphs: List<androidx.compose.ui.text.TextLayoutResult>,139 val tileWidth: Dp,140 val rowHeight: Dp,141 val patternWidthDp: Dp,142 val seed: Int,143)144145146private val ROW_SPEEDS_DP = floatArrayOf(8f, 11f, 6f)147148private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneLayer(149 layer: RuneLayer,150 elapsedSeconds: Float,151) {152 val tileWidth = layer.tileWidth.toPx()153 val rowHeight = layer.rowHeight.toPx()154 val patternWidth = layer.patternWidthDp.toPx()155 val columns = ceil(size.width / tileWidth).toInt() + PATTERN_COLUMNS * 2156 val rows = ceil(size.height / rowHeight).toInt() + 2157 val startColumn = -PATTERN_COLUMNS158159 for (row in -1 until rows) {160 val y = row * rowHeight161 162 163 val speedPx = ROW_SPEEDS_DP[positiveMod(row, ROW_SPEEDS_DP.size)] * density164 val offset = (elapsedSeconds * speedPx) % patternWidth165 for (i in 0..columns) {166 val column = startColumn + i167 val x = column * tileWidth - offset168 val patternColumn = positiveMod(column, PATTERN_COLUMNS)169 val glyphIndex = positiveMod(patternColumn * 31 + row * 17 + layer.seed, RUNE_COUNT)170 drawText(layer.glyphs[glyphIndex], topLeft = Offset(x, y))171 }172 }173}174175private fun positiveMod(value: Int, modulus: Int): Int = ((value % modulus) + modulus) % modulus