gioser

sergio/humanoid

HTML 57Markdown 2TOML 10 Definiciones

⎇ f01b7a1aa91e314295debf67c22ce5803495b2de launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt 371 líneas · 13.9 KB Historia crudo

Órbita: núcleo vivo, ronda sin saltos, orden de búsqueda y marca gioser.net f01b7a1aa · Sergio ·

1package com.rizoma.orbita.ui
2
3import androidx.compose.animation.core.LinearEasing
4import androidx.compose.animation.core.RepeatMode
5import androidx.compose.animation.core.Spring
6import androidx.compose.animation.core.animateFloat
7import androidx.compose.animation.core.animateFloatAsState
8import androidx.compose.animation.core.infiniteRepeatable
9import androidx.compose.animation.core.rememberInfiniteTransition
10import androidx.compose.animation.core.spring
11import androidx.compose.animation.core.tween
12import androidx.compose.foundation.Canvas
13import androidx.compose.foundation.ExperimentalFoundationApi
14import androidx.compose.foundation.Image
15import androidx.compose.foundation.border
16import androidx.compose.foundation.clickable
17import androidx.compose.foundation.combinedClickable
18import androidx.compose.foundation.interaction.MutableInteractionSource
19import androidx.compose.foundation.layout.Arrangement
20import androidx.compose.foundation.layout.BoxWithConstraints
21import androidx.compose.foundation.layout.Column
22import androidx.compose.foundation.layout.fillMaxSize
23import androidx.compose.foundation.layout.size
24import androidx.compose.foundation.layout.width
25import androidx.compose.foundation.shape.CircleShape
26import androidx.compose.material3.Text
27import androidx.compose.runtime.Composable
28import androidx.compose.runtime.State
29import androidx.compose.runtime.key
30import androidx.compose.runtime.remember
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.drawBehind
35import androidx.compose.ui.geometry.Offset
36import androidx.compose.ui.graphics.BlendMode
37import androidx.compose.ui.graphics.Brush
38import androidx.compose.ui.graphics.Color
39import androidx.compose.ui.graphics.drawscope.Stroke
40import androidx.compose.ui.graphics.graphicsLayer
41import androidx.compose.ui.graphics.lerp
42import androidx.compose.ui.platform.LocalDensity
43import androidx.compose.ui.text.font.FontWeight
44import androidx.compose.ui.text.style.TextAlign
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
47import androidx.compose.ui.unit.sp
48import com.rizoma.orbita.model.AppNode
49import com.rizoma.orbita.ui.theme.NeonCian
50import com.rizoma.orbita.ui.theme.NeonRosa
51import com.rizoma.orbita.ui.theme.NeonVerde
52import com.rizoma.orbita.ui.theme.NucleoPaleta
53import com.rizoma.orbita.ui.theme.Tenue
54import kotlin.math.PI
55import kotlin.math.cos
56import kotlin.math.floor
57import kotlin.math.max
58import kotlin.math.min
59import kotlin.math.sin
60
61/** La casilla orbital de un nodo: en qué anillo cae y con qué ángulo de partida. */
62private data class Slot(
63    val ring: Int,
64    val angle: Float,
65    val radius: Float,
66)
67
68/** El reparto entero: una casilla por clave de nodo, más cuántos anillos ocupa. */
69private data class Orbitas(val slots: Map<String, Slot>, val rings: Int)
70
71private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian)
72private const val TAU_F = (2 * PI).toFloat()
73
74/** Una vuelta completa del tinte del núcleo: tan larga que el cambio no se percibe. */
75private const val TINTE_MS = 900_000
76
77/**
78 * Distribución anti-rejilla: los nodos caen en órbitas concéntricas según su calor.
79 * Las órbitas internas (más cerca del centro) son las apps más usadas y las fijadas.
80 *
81 * Dos reglas mantienen la ronda estable (nada de saltos):
82 *  - el reparto en casillas se congela: el orden por calor decide la primera vez,
83 *    y a partir de ahí un nodo conserva su casilla aunque su calor cambie al usarlo;
84 *  - la posición se calcula en la fase de dibujo (graphicsLayer), no recomponiendo:
85 *    la vuelta va a 60 fps sin depender de que Compose recomponga cada fotograma.
86 */
87@OptIn(ExperimentalFoundationApi::class)
88@Composable
89fun OrbitLayout(
90    nodes: List<AppNode>,
91    onLaunch: (AppNode) -> Unit,
92    onLongPress: (AppNode) -> Unit,
93    onCenterTap: () -> Unit,
94    modifier: Modifier = Modifier,
95) {
96    val density = LocalDensity.current
97    val transition = rememberInfiniteTransition(label = "orbita")
98    // Sin `by`: el State se lee dentro de lambdas de dibujo, así el giro no recompone.
99    val spin = transition.animateFloat(
100        initialValue = 0f,
101        targetValue = TAU_F,
102        animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart),
103        label = "spin",
104    )
105    val pulse = transition.animateFloat(
106        initialValue = 0.94f,
107        targetValue = 1.06f,
108        animationSpec = infiniteRepeatable(tween(5_200, easing = LinearEasing), RepeatMode.Reverse),
109        label = "pulso",
110    )
111    // Deriva de color del núcleo: recorre la paleta entera en 15 minutos.
112    val tinte = transition.animateFloat(
113        initialValue = 0f,
114        targetValue = NucleoPaleta.size.toFloat(),
115        animationSpec = infiniteRepeatable(tween(TINTE_MS, easing = LinearEasing), RepeatMode.Restart),
116        label = "tinte",
117    )
118    val glow: () -> Color = { colorDelCiclo(tinte.value) }
119
120    BoxWithConstraints(modifier.fillMaxSize()) {
121        val wPx = with(density) { maxWidth.toPx() }
122        val hPx = with(density) { maxHeight.toPx() }
123        val cx = wPx / 2f
124        val cy = hPx / 2f
125
126        val firstR = with(density) { 116.dp.toPx() }
127        val ringGap = with(density) { 86.dp.toPx() }
128        val spacing = with(density) { 76.dp.toPx() }
129        val colHalf = with(density) { 32.dp.toPx() }
130        val iconHalf = with(density) { 27.dp.toPx() }
131
132        val orbitas = rememberOrbitas(nodes, firstR, ringGap, spacing)
133        val rings = orbitas.rings
134
135        // Halo del núcleo (mucho alcance, muy difuminado) + anillos orbitales.
136        Canvas(Modifier.fillMaxSize()) {
137            val c = glow()
138            val p = pulse.value
139            val halo = min(wPx, hPx) * 0.58f * p
140            drawCircle(
141                brush = Brush.radialGradient(
142                    0.00f to c.copy(alpha = 0.22f),
143                    0.16f to c.copy(alpha = 0.115f),
144                    0.38f to c.copy(alpha = 0.055f),
145                    0.66f to c.copy(alpha = 0.018f),
146                    1.00f to Color.Transparent,
147                    center = Offset(cx, cy),
148                    radius = halo,
149                ),
150                radius = halo,
151                center = Offset(cx, cy),
152                blendMode = BlendMode.Plus,
153            )
154            for (r in 0 until rings) {
155                // Los anillos se tiñen a medias con el color vivo del núcleo.
156                drawCircle(
157                    color = lerp(ringColors[r % ringColors.size], c, 0.5f).copy(alpha = 0.14f),
158                    radius = firstR + r * ringGap,
159                    center = Offset(cx, cy),
160                    style = Stroke(width = 1.2f),
161                )
162            }
163        }
164
165        // Núcleo: toque para buscar en el rizoma.
166        CenterHub(
167            modifier = Modifier.align(Alignment.Center),
168            glow = glow,
169            pulse = pulse,
170            onTap = onCenterTap,
171        )
172
173        // Nodos en órbita. Si una casilla cambia (se instala o se expulsa una app),
174        // el icono viaja hasta ella con un muelle lento: nunca reaparece en otro sitio.
175        nodes.forEach { node ->
176            val slot = orbitas.slots[node.key] ?: return@forEach
177            key(node.key) {
178                val angle = animateFloatAsState(
179                    targetValue = slot.angle,
180                    animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
181                    label = "ang",
182                )
183                val radius = animateFloatAsState(
184                    targetValue = slot.radius,
185                    animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
186                    label = "rad",
187                )
188                // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo.
189                val dir = if (slot.ring % 2 == 0) 1f else -1f
190                NodeItem(
191                    node = node,
192                    modifier = Modifier
193                        .align(Alignment.TopStart)
194                        .graphicsLayer {
195                            val a = angle.value + spin.value * dir
196                            translationX = cx + radius.value * cos(a) - colHalf
197                            translationY = cy + radius.value * sin(a) - iconHalf
198                        },
199                    onLaunch = onLaunch,
200                    onLongPress = onLongPress,
201                )
202            }
203        }
204    }
205}
206
207/**
208 * Casillas estables. El orden solo se recalcula cuando cambia la *firma* del átomo:
209 * qué apps hay, cuáles están fijadas y cuáles alejadas. El calor sube al usar una app,
210 * pero no mueve nada por su cuenta — el átomo se reordena cuando tú actúas sobre él,
211 * nunca a tus espaldas mientras miras la ronda. Y aun entonces, cada icono viaja a su
212 * nueva casilla con un muelle lento: nunca reaparece de golpe en otro sitio.
213 */
214@Composable
215private fun rememberOrbitas(
216    nodes: List<AppNode>,
217    firstR: Float,
218    ringGap: Float,
219    spacing: Float,
220): Orbitas {
221    val anguloPrevio = remember { HashMap<String, Float>() }
222    val firma = nodes.joinToString("|") { "${it.key}#${if (it.pinned) 1 else 0}#${it.penalty}" }
223    return remember(firma, firstR, ringGap, spacing) {
224        val slots = HashMap<String, Slot>(nodes.size)
225        var rings = 0
226        computePlacements(nodes.map { it.key }, firstR, ringGap, spacing).forEach { (clave, slot) ->
227            // El ángulo se "desenrolla" respecto al anterior para que la animación
228            // tome el camino corto y no dé una vuelta entera de más.
229            val previo = anguloPrevio[clave]
230            val angulo = if (previo == null) slot.angle else desenrollar(previo, slot.angle)
231            anguloPrevio[clave] = angulo
232            slots[clave] = slot.copy(angle = angulo)
233            rings = max(rings, slot.ring + 1)
234        }
235        anguloPrevio.keys.retainAll(slots.keys)
236        Orbitas(slots, rings)
237    }
238}
239
240private fun desenrollar(previo: Float, destino: Float): Float {
241    var a = destino
242    while (a - previo > TAU_F / 2f) a -= TAU_F
243    while (previo - a > TAU_F / 2f) a += TAU_F
244    return a
245}
246
247/** Color continuo a lo largo de la paleta del núcleo (t recorre 0..paleta.size). */
248private fun colorDelCiclo(t: Float): Color {
249    val n = NucleoPaleta.size
250    val base = floor(t).toInt()
251    val f = t - base
252    return lerp(NucleoPaleta[((base % n) + n) % n], NucleoPaleta[((base + 1) % n + n) % n], f)
253}
254
255@OptIn(ExperimentalFoundationApi::class)
256@Composable
257private fun NodeItem(
258    node: AppNode,
259    modifier: Modifier,
260    onLaunch: (AppNode) -> Unit,
261    onLongPress: (AppNode) -> Unit,
262) {
263    Column(
264        modifier = modifier.width(64.dp),
265        horizontalAlignment = Alignment.CenterHorizontally,
266    ) {
267        val iconMod = Modifier
268            .size(54.dp)
269            .clip(CircleShape)
270            .combinedClickable(
271                onClick = { onLaunch(node) },
272                onLongClick = { onLongPress(node) },
273            )
274            .then(
275                if (node.pinned) Modifier.border(1.5.dp, NeonRosa, CircleShape) else Modifier
276            )
277        Image(
278            bitmap = node.icon,
279            contentDescription = node.label,
280            modifier = iconMod,
281        )
282        Text(
283            text = node.label,
284            color = Tenue,
285            fontSize = 10.sp,
286            maxLines = 1,
287            overflow = TextOverflow.Ellipsis,
288            textAlign = TextAlign.Center,
289        )
290    }
291}
292
293/**
294 * El corazón del átomo: un núcleo blanco muy brillante envuelto en el color del ciclo.
295 * El halo amplio lo pinta el lienzo de fondo; aquí solo va el punto vivo y su toque.
296 */
297@Composable
298private fun CenterHub(
299    modifier: Modifier,
300    glow: () -> Color,
301    pulse: State<Float>,
302    onTap: () -> Unit,
303) {
304    Column(
305        modifier = modifier
306            .size(96.dp)
307            .clip(CircleShape)
308            .drawBehind {
309                val c = glow()
310                val r = size.minDimension / 2f * pulse.value
311                drawCircle(
312                    brush = Brush.radialGradient(
313                        0.00f to Color.White.copy(alpha = 0.42f),
314                        0.10f to c.copy(alpha = 0.80f),
315                        0.35f to c.copy(alpha = 0.34f),
316                        0.68f to c.copy(alpha = 0.10f),
317                        1.00f to Color.Transparent,
318                        center = center,
319                        radius = r,
320                    ),
321                    radius = r,
322                    center = center,
323                    blendMode = BlendMode.Plus,
324                )
325            }
326            .clickable(
327                interactionSource = remember { MutableInteractionSource() },
328                indication = null,
329                onClick = onTap,
330            ),
331        horizontalAlignment = Alignment.CenterHorizontally,
332        verticalArrangement = Arrangement.Center,
333    ) {
334        Text(
335            text = "\u2726",
336            color = Color.White.copy(alpha = 0.92f),
337            fontSize = 24.sp,
338            fontWeight = FontWeight.Light,
339        )
340    }
341}
342
343/**
344 * Reparte las claves (ya ordenadas) en anillos. Cada anillo crece hacia afuera y su
345 * capacidad depende de su circunferencia, así nada se amontona.
346 */
347private fun computePlacements(
348    keys: List<String>,
349    firstR: Float,
350    ringGap: Float,
351    spacing: Float,
352): List<Pair<String, Slot>> {
353    if (keys.isEmpty()) return emptyList()
354    val result = ArrayList<Pair<String, Slot>>(keys.size)
355    var index = 0
356    var ring = 0
357    while (index < keys.size) {
358        val radius = firstR + ring * ringGap
359        val circumference = 2.0 * PI * radius
360        val capacity = max(4, floor(circumference / spacing).toInt())
361        val count = min(capacity, keys.size - index)
362        val base = ring * 0.5f
363        for (j in 0 until count) {
364            val angle = base + TAU_F * j / count
365            result.add(keys[index + j] to Slot(ring, angle, radius))
366        }
367        index += count
368        ring++
369    }
370    return result
371}