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
+
+
+
+
+
+
+
+
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,
+ )
+ }
+ }
+}