⎇ f01b7a1aa91e314295debf67c22ce5803495b2de
launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
Ñawpa willay
ch'uya
Órbita: núcleo vivo, ronda sin saltos, orden de búsqueda y marca gioser.net
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}