owenclave / app/src/main/java/io/nekohasekai/sagernet/ui/compose/components/AppLogo.kt

github - dev - back to tree - upstream
package io.nekohasekai.sagernet.ui.compose.components import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** * A single layer of a logo: a vector path (in a 256x256 viewport) filled with a * muted blue-grey tone. */ internal data class LogoLayer( val pathData: String, val color: Color, val alpha: Float = 1f, ) /** * A selectable app logo. All logos share the muted blue-grey ("Duster") palette * and carry a transparent, punched-out letter "O" in the centre. */ enum class AppLogoStyle( val id: Int, val label: String, internal val layers: List, internal val oOuter: Float = 46f, internal val oInner: Float = 26f, /** When set, the O is drawn as a solid ring instead of a transparent cut-out. */ internal val ringO: Boolean = false, ) { SPLIT( 0, "Split", listOf( LogoLayer( "M 118.00 -2.60 C 143.13 0.07 165.90 43.45 183.37 67.87 C 200.84 92.29 227.50 122.36 222.80 143.92 C 218.10 165.47 181.69 182.41 155.18 197.20 C 128.67 211.99 87.64 241.49 63.75 232.66 C 39.85 223.83 17.00 174.37 11.81 144.24 C 6.62 114.11 14.89 76.36 32.59 51.89 C 50.29 27.42 92.87 -5.26 118.00 -2.60 Z", Color(0xFF4A5866), ), LogoLayer( "M 140.00 49.04 C 169.05 49.44 211.58 37.24 225.94 52.06 C 240.29 66.89 226.29 109.53 226.12 138.00 C 225.94 166.47 239.25 203.70 224.89 222.89 C 210.54 242.09 167.52 253.95 140.00 253.17 C 112.48 252.39 75.15 237.40 59.79 218.21 C 44.44 199.01 49.20 166.09 47.85 138.00 C 46.49 109.91 36.30 64.48 51.65 49.65 C 67.01 34.83 110.95 48.64 140.00 49.04 Z", Color(0xFF5B6B7B), alpha = 0.92f, ), ), ), FLOWER( 1, "Flower", listOf(LogoLayer(scalloped(128f, 128f, 116f, 8, 32f), Color(0xFF5B6B7B))), ), COOKIE( 2, "Cookie", listOf(LogoLayer(scalloped(128f, 128f, 116f, 9, 20f), Color(0xFF647585))), ), SUNNY( 3, "Sunny", listOf(LogoLayer(scalloped(128f, 128f, 116f, 12, 24f), Color(0xFF516170))), oOuter = 44f, oInner = 25f, ), BLOB( 4, "Blob", listOf(LogoLayer(blob(21, 128f, 128f, 116f, 9, 0.28f), Color(0xFF5B6B7B))), ), BEAN( 5, "Bean", listOf(LogoLayer(bean(128f, 128f, 96f), Color(0xFF5B6B7B))), oOuter = 42f, oInner = 24f, ), GEM( 6, "Gem", listOf( LogoLayer( "M128 18 C150 18 168 30 188 42 C208 54 220 66 226 92 C232 118 226 138 226 164 C226 190 208 202 188 214 C168 226 150 238 128 238 C106 238 88 226 68 214 C48 202 30 190 30 164 C30 138 24 118 30 92 C36 66 48 54 68 42 C88 30 106 18 128 18 Z", Color(0xFF5B6B7B), ), ), oOuter = 50f, ringO = true, ), ; companion object { fun fromId(id: Int): AppLogoStyle = entries.firstOrNull { it.id == id } ?: SPLIT } } private val oRingColor = Color(0xFFAEBCC9) /** * Draws the selected [style] logo, tinted with its own muted palette. Renders * the punched-out "O" using a transparent cut-out (respecting alpha) so it looks * correct on any background. */ @Composable fun AppLogo( style: AppLogoStyle, modifier: Modifier = Modifier, size: Dp = 96.dp, ) { val parsedLayers = remember(style) { style.layers.map { it to PathParser().parsePathString(it.pathData).toPath() } } Canvas(modifier = modifier.size(size)) { val s = this.size.minDimension / 256f scale(s, s, pivot = Offset.Zero) { drawLogo(style, parsedLayers) } } } private fun DrawScope.drawLogo( style: AppLogoStyle, parsedLayers: List>, ) { if (style.ringO) { // Solid-fill shape + a light ring for the O. parsedLayers.forEach { (layer, path) -> drawPath(path, color = layer.color, alpha = layer.alpha, style = Fill) } drawCircle( color = oRingColor, radius = style.oOuter, center = Offset(128f, 128f), style = Stroke(width = (style.oOuter - style.oInner)), ) } else { // Draw all layers into an offscreen layer, then punch a transparent O. val paint = androidx.compose.ui.graphics.Paint() drawContext.canvas.saveLayer( androidx.compose.ui.geometry.Rect(0f, 0f, 256f, 256f), paint, ) parsedLayers.forEach { (layer, path) -> drawPath(path, color = layer.color, alpha = layer.alpha, style = Fill) } // Clear an annulus: clear the outer disc, then restore the inner disc. drawCircle( color = Color.Transparent, radius = style.oOuter, center = Offset(128f, 128f), blendMode = BlendMode.Clear, ) drawContext.canvas.restore() } } // ── Procedural shape generators (mirror the SVG preview generator) ── private fun catmullRom(pts: List): String { val p = ArrayList(pts.size + 3) p.add(pts.last()); p.addAll(pts); p.add(pts[0]); p.add(pts[1]) val sb = StringBuilder() sb.append("M ${p[1].x} ${p[1].y} ") for (i in 1 until p.size - 2) { val p0 = p[i - 1]; val p1 = p[i]; val p2 = p[i + 1]; val p3 = p[i + 2] val c1x = p1.x + (p2.x - p0.x) / 6; val c1y = p1.y + (p2.y - p0.y) / 6 val c2x = p2.x - (p3.x - p1.x) / 6; val c2y = p2.y - (p3.y - p1.y) / 6 sb.append("C $c1x $c1y $c2x $c2y ${p2.x} ${p2.y} ") } sb.append("Z") return sb.toString() } private fun scalloped(cx: Float, cy: Float, R: Float, petals: Int, depth: Float): String { val steps = petals * 40 val pts = ArrayList(steps) for (i in 0 until steps) { val a = 2 * Math.PI * i / steps val r = R - depth * (0.5 - 0.5 * Math.cos(petals * a)) pts.add( Offset( (cx + r * Math.cos(a - Math.PI / 2)).toFloat(), (cy + r * Math.sin(a - Math.PI / 2)).toFloat(), ), ) } // scalloped uses straight segments in the SVG; polyline is fine here too. val sb = StringBuilder("M ${pts[0].x} ${pts[0].y} ") for (i in 1 until pts.size) sb.append("L ${pts[i].x} ${pts[i].y} ") sb.append("Z") return sb.toString() } private fun blob(seed: Int, cx: Float, cy: Float, R: Float, n: Int, jitter: Float): String { var a = seed fun rnd(): Double { a = a + -0x61c88647 var t = a xor (a ushr 15) t *= 1 or a t = t xor (t + (t xor (t ushr 7)) * (61 or t)) return ((t xor (t ushr 14)).toLong() and 0xFFFFFFFFL).toDouble() / 4294967296.0 } val pts = ArrayList(n) for (i in 0 until n) { val ang = 2 * Math.PI * i / n val r = R * (1 - jitter + jitter * 2 * rnd()) pts.add( Offset( (cx + r * Math.cos(ang - Math.PI / 2)).toFloat(), (cy + r * Math.sin(ang - Math.PI / 2)).toFloat(), ), ) } return catmullRom(pts) } private fun bean(cx: Float, cy: Float, R: Float): String { val n = 72 val pts = ArrayList(n) for (i in 0 until n) { val t = 2 * Math.PI * i / n val r = R * (1 + 0.28 * Math.cos(t)) * (1 - 0.18 * Math.cos(2 * t)) pts.add( Offset( (cx + r * Math.cos(t)).toFloat(), (cy + r * Math.sin(t) * 0.9).toFloat(), ), ) } return catmullRom(pts) }