diff --git a/AGENTS.md b/AGENTS.md index 69347084..943bd754 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,3 +111,12 @@ transition to OMM/CSV automatically. - SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass. - `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules. - ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking. + +## Copilot Working Mode: Code-Only + +- Default to code changes only. Provide explanations in chat only. +- If documentation seems useful, ask first before creating files. +- Do NOT create any `.md` documentation files unless explicitly requested. +- Do NOT add README, guides, summaries, migration notes, or how-to files unless asked. +- Prefer minimal diffs focused on requested implementation. +- Default validation is static checks (`get_errors`). Do NOT run Gradle compile/test tasks unless explicitly requested. diff --git a/README.md b/README.md index 8c4b0c5d..78fbd1b8 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,13 @@ It is now and always will be completely ad-free and open-source. * Showing the satellite positional data, footprint and ground track on the map * Custom TLE satellite data import is available via Three Line Element .txt files * Offline first: calculations are made offline. Weekly TLE data update is recommended. + +## Star History + + + + + + Star History Chart + + diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/BubblesEffect.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/BubblesEffect.kt new file mode 100644 index 00000000..ff97a435 --- /dev/null +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/BubblesEffect.kt @@ -0,0 +1,424 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.presentation + +import android.graphics.Paint +import android.graphics.LinearGradient +import android.graphics.RadialGradient +import android.graphics.Shader +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.nativeCanvas +import androidx.core.graphics.withRotation +import kotlinx.coroutines.isActive +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.random.Random + +@Immutable +data class BubblesStyle( + val bgColor: Color = Color.Black, + val bubbleCount: Int = 16, + val minBubbleCount: Int = 8, + val maxBubbleCount: Int = 16, + val adaptiveBubbleCount: Boolean = true, + val spawnIntervalMs: Long = 800L, + val bubbleRadiusFraction: Float = 0.24f, + val adaptiveSizing: Boolean = true, + val referenceMinSizePx: Float = 360f, + val referenceAreaPx: Float = 360f * 800f, + val minBubbleRadiusPx: Float = 16f, + val maxBubbleRadiusPx: Float = 256f, + val speedScale: Float = 0.99f, + val minVelocity: Float = 0.8f, + val maxVelocity: Float = 3.2f, + val hueRotationSpeedDps: Float = 60f, // degrees per second +) + +@Composable +fun BubblesEffect( + modifier: Modifier = Modifier, + isRunning: Boolean = true, + style: BubblesStyle = BubblesStyle(), +) { + val renderer = remember { BubblesRenderer() } + var frameSignal by remember { mutableIntStateOf(0) } + + // Animation loop + LaunchedEffect(isRunning, style) { + if (!isRunning) return@LaunchedEffect + var previousNanos = 0L + while (isActive) { + withFrameNanos { now -> + if (previousNanos == 0L) previousNanos = now + val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS + previousNanos = now + renderer.update(deltaSec, style) + frameSignal++ + } + } + } + Canvas(modifier = modifier) { + frameSignal + renderer.ensureLayout(size) + renderer.draw(this, style) + } +} + +private const val MAX_STEP_NANOS = 16_666_667L // ~60 FPS +private const val NANOS_TO_SECONDS = 1_000_000_000f +private val SHELL_GRADIENT_STOPS = floatArrayOf(0f, 0.52f, 0.66f, 0.79f, 0.90f, 0.968f, 0.993f, 1f) +private val INNER_GRADIENT_STOPS = floatArrayOf(0f, 0.34f, 0.68f, 1f) + +private data class Velocity(var x: Float, var y: Float) + +private data class Bubble( + var x: Float, + var y: Float, + val radius: Float, + val velocity: Velocity, + val baseHue: Float, // 0-360, unique for each bubble +) + +private class BubblesRenderer { + private var width = 0f + private var height = 0f + private val bubbles = mutableListOf() + private val random = Random(System.currentTimeMillis()) + private var globalHueRotation = 0f // degrees, rotates all bubbles hues + private var spawnAccumulatorSec = 0f + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val shellColors = IntArray(8) + private val innerColors = IntArray(4) + + fun ensureLayout(size: Size) { + val targetWidth = size.width + val targetHeight = size.height + if (targetWidth <= 0f || targetHeight <= 0f) return + val shouldRebuild = width != targetWidth || height != targetHeight + if (shouldRebuild) { + width = targetWidth + height = targetHeight + bubbles.clear() + spawnAccumulatorSec = 0f + } + } + + fun spawnBubble(style: BubblesStyle): Boolean { + if (width <= 0f || height <= 0f) return false + val radius = resolveBubbleRadius(style) + val (velocityMin, velocityMax) = resolveVelocityRange(style) + val bubble = Bubble( + x = radius, + y = height - radius, + radius = radius, + velocity = Velocity( + x = randomInRange(velocityMin, velocityMax), + y = -randomInRange(velocityMin, velocityMax), + ), + baseHue = random.nextFloat() * 360f, // Random starting hue for this bubble + ) + bubbles.add(bubble) + return true + } + + fun update(deltaSeconds: Float, style: BubblesStyle) { + if (width <= 0f || height <= 0f) return + + spawnMissingBubbles(deltaSeconds, style) + + // Rotate hue for all bubbles + globalHueRotation += style.hueRotationSpeedDps * deltaSeconds + if (globalHueRotation >= 360f) globalHueRotation -= 360f + val frameScale = deltaSeconds * 60f + for (i in bubbles.indices) { + val bubble = bubbles[i] + // Update position + bubble.x += bubble.velocity.x * frameScale + bubble.y += bubble.velocity.y * frameScale + // Bounce off walls + if (bubble.x > width - bubble.radius) { + bubble.x = width - bubble.radius + bubble.velocity.x *= -1 + } + if (bubble.x < bubble.radius) { + bubble.x = bubble.radius + bubble.velocity.x *= -1 + } + if (bubble.y > height - bubble.radius) { + bubble.y = height - bubble.radius + bubble.velocity.y *= -1 + } + if (bubble.y < bubble.radius) { + bubble.y = bubble.radius + bubble.velocity.y *= -1 + } + } + // Collision detection and resolution + for (i in bubbles.indices) { + for (j in (i + 1) until bubbles.size) { + val b1 = bubbles[i] + val b2 = bubbles[j] + if (isCollided(b1, b2)) resolveCollision(b1, b2) + } + } + } + + private fun spawnMissingBubbles(deltaSeconds: Float, style: BubblesStyle) { + val targetCount = resolveTargetBubbleCount(style) + if (bubbles.size >= targetCount) return + + val spawnIntervalSec = style.spawnIntervalMs.coerceAtLeast(1L) / 1_000f + spawnAccumulatorSec += deltaSeconds + + while (bubbles.size < targetCount && spawnAccumulatorSec >= spawnIntervalSec) { + if (!spawnBubble(style)) break + spawnAccumulatorSec -= spawnIntervalSec + } + } + + fun draw(scope: DrawScope, style: BubblesStyle) { + if (width <= 0f || height <= 0f) return + scope.drawRect(style.bgColor) + val canvas = scope.drawContext.canvas.nativeCanvas + for (i in bubbles.indices) { + drawBubbleWithGradient(canvas, bubbles[i]) + } + } + + private fun drawBubbleWithGradient(canvas: android.graphics.Canvas, bubble: Bubble) { + val hue = (bubble.baseHue + globalHueRotation) % 360f + val lit = 53.33f + maxOf(0f, (70f - kotlin.math.abs(244f - hue)) / 4f) + val rgb = hueToRgb(hue, lit) + val shellColor = mixWithWhite(rgb, 0.04f) + val innerColor = mixWithWhite(rgb, 0.10f) + val shimmerColor = mixWithWhite(rgb, 0.35f) + // Outer shell + val shellGradient = RadialGradient( + bubble.x, bubble.y, bubble.radius, + buildShellColors(shellColor), + SHELL_GRADIENT_STOPS, + Shader.TileMode.CLAMP + ) + paint.style = Paint.Style.FILL + paint.shader = shellGradient + canvas.drawCircle(bubble.x, bubble.y, bubble.radius, paint) + // Subtle thin rim + paint.shader = null + paint.style = Paint.Style.STROKE + paint.strokeWidth = maxOf(1f, bubble.radius * 0.024f) + paint.color = colorWithAlpha(mixWithWhite(shellColor, 0.08f), 0.30f) + canvas.drawCircle(bubble.x, bubble.y, bubble.radius - paint.strokeWidth * 0.5f, paint) + // Top internal bubble + val innerTop = bubble.y - bubble.radius * 0.96f + val innerBottom = bubble.y + bubble.radius * 0.48f + val innerLeft = bubble.x - bubble.radius * 0.84f + val innerRight = bubble.x + bubble.radius * 0.84f + val innerGradient = LinearGradient( + bubble.x, + innerTop, + bubble.x, + innerBottom, + buildInnerColors(innerColor), + INNER_GRADIENT_STOPS, + Shader.TileMode.CLAMP + ) + paint.style = Paint.Style.FILL + paint.shader = innerGradient + canvas.drawOval( + innerLeft, + innerTop, + innerRight, + innerBottom, + paint + ) + // Top-left shimmer + paint.shader = null + paint.style = Paint.Style.FILL + paint.color = colorWithAlpha(shimmerColor, 0.95f) + val shimmerCx = bubble.x - bubble.radius * 0.40f + val shimmerCy = bubble.y - bubble.radius * 0.72f + val shimmerHalfWidth = bubble.radius * 0.06f + val shimmerHalfHeight = bubble.radius * 0.21f + canvas.withRotation(60f, shimmerCx, shimmerCy) { + drawOval( + shimmerCx - shimmerHalfWidth, + shimmerCy - shimmerHalfHeight, + shimmerCx + shimmerHalfWidth, + shimmerCy + shimmerHalfHeight, + paint + ) + } + } + + private fun isCollided(bubble1: Bubble, bubble2: Bubble): Boolean { + val dx = bubble1.x - bubble2.x + val dy = bubble1.y - bubble2.y + val radius = (bubble1.radius + bubble2.radius) * 0.9f + return dx * dx + dy * dy < radius * radius + } + + private fun resolveCollision(particle: Bubble, otherParticle: Bubble) { + val xVelocityDiff = particle.velocity.x - otherParticle.velocity.x + val yVelocityDiff = particle.velocity.y - otherParticle.velocity.y + val xDist = otherParticle.x - particle.x + val yDist = otherParticle.y - particle.y + // Prevent accidental overlap + if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) { + val angle = -atan2(otherParticle.y - particle.y, otherParticle.x - particle.x) + val m1 = 1f + val m2 = 1f + val u1 = rotate(particle.velocity, angle) + val u2 = rotate(otherParticle.velocity, angle) + val v1 = Velocity( + x = (u1.x * (m1 - m2)) / (m1 + m2) + (u2.x * 2 * m2) / (m1 + m2), + y = u1.y, + ) + val v2 = Velocity( + x = (u2.x * (m1 - m2)) / (m1 + m2) + (u1.x * 2 * m2) / (m1 + m2), + y = u2.y, + ) + val vFinal1 = rotate(v1, -angle) + val vFinal2 = rotate(v2, -angle) + particle.velocity.x = vFinal1.x + particle.velocity.y = vFinal1.y + otherParticle.velocity.x = vFinal2.x + otherParticle.velocity.y = vFinal2.y + } + } + + private fun rotate(velocity: Velocity, angle: Float): Velocity { + return Velocity( + x = velocity.x * cos(angle) - velocity.y * sin(angle), + y = velocity.x * sin(angle) + velocity.y * cos(angle), + ) + } + + private fun buildShellColors(shellColor: Int): IntArray { + shellColors[0] = colorWithAlpha(shellColor, 0f) + shellColors[1] = colorWithAlpha(shellColor, 0f) + shellColors[2] = colorWithAlpha(shellColor, 0.04f) + shellColors[3] = colorWithAlpha(shellColor, 0.12f) + shellColors[4] = colorWithAlpha(shellColor, 0.28f) + shellColors[5] = colorWithAlpha(shellColor, 0.44f) + shellColors[6] = colorWithAlpha(shellColor, 0.58f) + shellColors[7] = colorWithAlpha(shellColor, 0.64f) + return shellColors + } + + private fun buildInnerColors(innerColor: Int): IntArray { + innerColors[0] = colorWithAlpha(innerColor, 0.48f) + innerColors[1] = colorWithAlpha(innerColor, 0.36f) + innerColors[2] = colorWithAlpha(innerColor, 0.08f) + innerColors[3] = colorWithAlpha(innerColor, 0f) + return innerColors + } + + private fun resolveBubbleRadius(style: BubblesStyle): Float { + val minDimension = min(width, height) + val baseRadius = minDimension * style.bubbleRadiusFraction + val minRadius = style.minBubbleRadiusPx.coerceAtLeast(1f) + val maxRadius = maxOf(minRadius, style.maxBubbleRadiusPx) + if (!style.adaptiveSizing || minDimension <= 0f) { + return baseRadius.coerceIn(minRadius, maxRadius) + } + + val reference = style.referenceMinSizePx.coerceAtLeast(1f) + val dampening = sqrt((reference / minDimension).coerceAtMost(1f)) + return (baseRadius * dampening).coerceIn(minRadius, maxRadius) + } + + private fun resolveTargetBubbleCount(style: BubblesStyle): Int { + val baseCount = style.bubbleCount.coerceAtLeast(1) + if (!style.adaptiveBubbleCount) return baseCount + + val area = width * height + val referenceArea = style.referenceAreaPx.coerceAtLeast(1f) + val areaScale = sqrt((area / referenceArea).coerceAtLeast(0.25f)) + val scaledCount = (baseCount * areaScale).toInt() + val minCount = style.minBubbleCount.coerceAtLeast(1) + val maxCount = maxOf(minCount, style.maxBubbleCount.coerceAtLeast(1)) + return scaledCount.coerceIn(minCount, maxCount) + } + + private fun resolveVelocityRange(style: BubblesStyle): Pair { + val referenceMaxVelocity = maxOf(height / 300f, 1f) + val velocityMin = (style.minVelocity * style.speedScale).coerceAtLeast(0.05f) + val velocityMax = maxOf(velocityMin, min(style.maxVelocity, referenceMaxVelocity) * style.speedScale) + return velocityMin to velocityMax + } + + private fun randomInRange(min: Float, max: Float): Float { + return random.nextFloat() * (max - min) + min + } +} + +private fun hueToRgb(h: Float, l: Float): Int { + val hNorm = h / 360f + val lNorm = l / 100f + val sNorm = 1f + val c = (1f - kotlin.math.abs(2f * lNorm - 1f)) * sNorm + val hp = hNorm * 6f + val x = c * (1f - kotlin.math.abs((hp % 2f) - 1f)) + val m = lNorm - c / 2f + val (r, g, b) = when { + hp < 1f -> Triple(c, x, 0f) + hp < 2f -> Triple(x, c, 0f) + hp < 3f -> Triple(0f, c, x) + hp < 4f -> Triple(0f, x, c) + hp < 5f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + val r8 = ((r + m) * 255).toInt().coerceIn(0, 255) + val g8 = ((g + m) * 255).toInt().coerceIn(0, 255) + val b8 = ((b + m) * 255).toInt().coerceIn(0, 255) + return (0xFF shl 24) or (r8 shl 16) or (g8 shl 8) or b8 +} + +private fun colorWithAlpha(color: Int, alpha: Float): Int { + val a = (alpha * 255).toInt().coerceIn(0, 255) + val r = (color shr 16) and 0xFF + val g = (color shr 8) and 0xFF + val b = color and 0xFF + return (a shl 24) or (r shl 16) or (g shl 8) or b +} + +private fun mixWithWhite(color: Int, amount: Float): Int { + val t = amount.coerceIn(0f, 1f) + val r = (color shr 16) and 0xFF + val g = (color shr 8) and 0xFF + val b = color and 0xFF + val mixedR = (r + (255 - r) * t).toInt().coerceIn(0, 255) + val mixedG = (g + (255 - g) * t).toInt().coerceIn(0, 255) + val mixedB = (b + (255 - b) * t).toInt().coerceIn(0, 255) + return (0xFF shl 24) or (mixedR shl 16) or (mixedG shl 8) or mixedB +} diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MatrixEffect.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MatrixEffect.kt new file mode 100644 index 00000000..47433a7e --- /dev/null +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MatrixEffect.kt @@ -0,0 +1,260 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.presentation + +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.isActive +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.random.Random + +@Immutable +data class MatrixStyle( + val bgColor: Color = Color.Black, + val bodyColor: Color = Color(0xFF29C94A), + val headColor: Color = Color(0xFFC2FFC6), + val tailAlphaFloor: Float = 0.14f, // 0.14f - 0.18f, + val fontSize: TextUnit = 14.sp, // 11.sp - 14.sp + val minStreamLength: Int = 8, // 6 - 8 + val maxStreamLength: Int = 28, // 20 - 28 + val minSpeedRps: Float = 10f, // 10f - 15f + val maxSpeedRps: Float = 38f, // 38f - 45f + val resetPauseSec: ClosedFloatingPointRange = 0.1f..1.0f, +) + +@Composable +fun MatrixEffect( + modifier: Modifier = Modifier, + isRunning: Boolean = true, + style: MatrixStyle = MatrixStyle(), + symbols: String = DEFAULT_SYMBOLS, +) { + val density = LocalDensity.current + val renderer = remember { MatrixRenderer() } + var frameSignal by remember { mutableIntStateOf(0) } + + LaunchedEffect(isRunning, style, symbols) { + if (!isRunning) return@LaunchedEffect + + var previousNanos = 0L + while (isActive) { + withFrameNanos { now -> + if (previousNanos == 0L) previousNanos = now + val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS + previousNanos = now + renderer.update(deltaSec, style) + frameSignal++ + } + } + } + + Canvas(modifier = modifier) { + frameSignal + renderer.ensureLayout(size, density.density, style, symbols) + renderer.draw(this, style) + } +} + +private const val MAX_STEP_NANOS = 33_333_333L +private const val NANOS_TO_SECONDS = 1_000_000_000f +private const val DEFAULT_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*+-~:;/[]{}()" + +private fun Color.toArgb(): Int { + val a = (alpha.coerceIn(0f, 1f) * 255f + 0.5f).toInt() + val r = (red.coerceIn(0f, 1f) * 255f + 0.5f).toInt() + val g = (green.coerceIn(0f, 1f) * 255f + 0.5f).toInt() + val b = (blue.coerceIn(0f, 1f) * 255f + 0.5f).toInt() + return (a shl 24) or (r shl 16) or (g shl 8) or b +} + +private class MatrixRenderer { + private var columns = 0 + private var rows = 0 + private var width = 0f + private var height = 0f + private var fontSizePx = 0f + private var charWidth = 0f + private var charHeight = 0f + private var baselineOffset = 0f + private var symbolSet = "" + private var symbols = charArrayOf() + + private var glyphs = charArrayOf() + private var streams = emptyArray() + + private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE } + private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE } + private val charBuffer = CharArray(1) + private val random = Random(System.currentTimeMillis()) + + fun ensureLayout(size: Size, density: Float, style: MatrixStyle, symbols: String) { + val targetFontPx = style.fontSize.value * density + val targetWidth = size.width + val targetHeight = size.height + val targetSymbols = symbols.ifBlank { DEFAULT_SYMBOLS } + + if (targetWidth <= 0f || targetHeight <= 0f) return + + val shouldRebuild = width != targetWidth || + height != targetHeight || + fontSizePx != targetFontPx || + symbolSet != targetSymbols + + if (!shouldRebuild) return + + width = targetWidth + height = targetHeight + fontSizePx = targetFontPx + symbolSet = targetSymbols + this.symbols = targetSymbols.toCharArray() + + bodyPaint.textSize = targetFontPx + headPaint.textSize = targetFontPx + charWidth = max(bodyPaint.measureText("W"), 1f) + val metrics = bodyPaint.fontMetrics + charHeight = max(metrics.descent - metrics.ascent, 1f) + baselineOffset = -metrics.ascent + + columns = max((width / charWidth).toInt(), 1) + rows = max((height / charHeight).toInt() + 2, 1) + + glyphs = CharArray(columns * rows) { randomGlyph() } + streams = Array(columns) { StreamState.random(rows, style, random) } + } + + fun update(deltaSeconds: Float, style: MatrixStyle) { + if (columns == 0 || rows == 0 || deltaSeconds <= 0f) return + + for (column in streams.indices) { + val stream = streams[column] + stream.pauseSec -= deltaSeconds + if (stream.pauseSec > 0f) continue + + val previousHead = floor(stream.headRow).toInt() + stream.headRow += stream.speedRps * deltaSeconds + val newHead = floor(stream.headRow).toInt() + + if (newHead > previousHead) { + for (row in (previousHead + 1)..newHead) { + if (row in 0 until rows) { + glyphs[row * columns + column] = randomGlyph() + } + } + } + + if (newHead - stream.length > rows) { + stream.reset(rows, style, random) + } + } + } + + fun draw(scope: androidx.compose.ui.graphics.drawscope.DrawScope, style: MatrixStyle) { + if (columns == 0 || rows == 0) return + + scope.drawRect(style.bgColor) + + bodyPaint.color = style.bodyColor.toArgb() + headPaint.color = style.headColor.toArgb() + val tailAlphaFloor = style.tailAlphaFloor.coerceIn(0f, 1f) + + val canvas = scope.drawContext.canvas.nativeCanvas + for (column in streams.indices) { + val stream = streams[column] + if (stream.pauseSec > 0f) continue + + val head = floor(stream.headRow).toInt() + val startRow = max(0, head - stream.length + 1) + val endRow = min(rows - 1, head) + if (startRow > endRow) continue + + for (row in endRow downTo startRow) { + val tailIndex = head - row + + val paint = if (tailIndex == 0) headPaint else bodyPaint + if (tailIndex != 0) { + val normalized = ((stream.length - tailIndex).toFloat() / stream.length).coerceIn(0f, 1f) + val alpha = tailAlphaFloor + (1f - tailAlphaFloor) * normalized + paint.alpha = (alpha * 255).toInt() + } else { + paint.alpha = 255 + } + + val glyph = glyphs[row * columns + column] + charBuffer[0] = glyph + val x = column * charWidth + val y = row * charHeight + baselineOffset + canvas.drawText(charBuffer, 0, 1, x, y, paint) + } + } + } + + private fun randomGlyph(): Char = symbols[random.nextInt(symbols.size)] +} + +private data class StreamState( + var headRow: Float, + var length: Int, + var speedRps: Float, + var pauseSec: Float, +) { + fun reset(rows: Int, style: MatrixStyle, random: Random) { + val randomOffset = random.nextFloat() * rows + headRow = -randomOffset + length = random.nextInt( + from = style.minStreamLength.coerceAtLeast(2), + until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1), + ) + speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps + pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) + + style.resetPauseSec.start + } + + companion object { + fun random(rows: Int, style: MatrixStyle, random: Random): StreamState { + val length = random.nextInt( + from = style.minStreamLength.coerceAtLeast(2), + until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1), + ) + return StreamState( + headRow = -random.nextFloat() * rows, + length = length, + speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps, + pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) + + style.resetPauseSec.start, + ) + } + } +}