⎇ a4998fe44b630e7fbb929875d300c82de41ca981
launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
Ñawpa willay
ch'uya
Órbita: el núcleo es una figura viva, no un punto
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.combinedClickable
17import androidx.compose.foundation.layout.BoxWithConstraints
18import androidx.compose.foundation.layout.Column
19import androidx.compose.foundation.layout.fillMaxSize
20import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.layout.width
22import androidx.compose.foundation.shape.CircleShape
23import androidx.compose.material3.Text
24import androidx.compose.runtime.Composable
25import androidx.compose.runtime.key
26import androidx.compose.runtime.remember
27import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
29import androidx.compose.ui.draw.clip
30import androidx.compose.ui.geometry.Offset
31import androidx.compose.ui.graphics.BlendMode
32import androidx.compose.ui.graphics.Brush
33import androidx.compose.ui.graphics.Color
34import androidx.compose.ui.graphics.drawscope.Stroke
35import androidx.compose.ui.graphics.graphicsLayer
36import androidx.compose.ui.graphics.lerp
37import androidx.compose.ui.platform.LocalDensity
38import androidx.compose.ui.text.style.TextAlign
39import androidx.compose.ui.text.style.TextOverflow
40import androidx.compose.ui.unit.dp
41import androidx.compose.ui.unit.sp
42import com.rizoma.orbita.model.AppNode
43import com.rizoma.orbita.ui.theme.NeonCian
44import com.rizoma.orbita.ui.theme.NeonRosa
45import com.rizoma.orbita.ui.theme.NeonVerde
46import com.rizoma.orbita.ui.theme.Tenue
47import kotlin.math.PI
48import kotlin.math.cos
49import kotlin.math.floor
50import kotlin.math.max
51import kotlin.math.min
52import kotlin.math.sin
53
54/** La casilla orbital de un nodo: en qué anillo cae y con qué ángulo de partida. */
55private data class Slot(
56 val ring: Int,
57 val angle: Float,
58 val radius: Float,
59)
60
61/** El reparto entero: una casilla por clave de nodo, más cuántos anillos ocupa. */
62private data class Orbitas(val slots: Map<String, Slot>, val rings: Int)
63
64private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian)
65private const val TAU_F = (2 * PI).toFloat()
66
67/**
68 * Distribución anti-rejilla: los nodos caen en órbitas concéntricas según su calor.
69 * Las órbitas internas (más cerca del centro) son las apps más usadas y las fijadas.
70 *
71 * Dos reglas mantienen la ronda estable (nada de saltos):
72 * - el reparto en casillas se congela: el orden por calor decide la primera vez,
73 * y a partir de ahí un nodo conserva su casilla aunque su calor cambie al usarlo;
74 * - la posición se calcula en la fase de dibujo (graphicsLayer), no recomponiendo:
75 * la vuelta va a 60 fps sin depender de que Compose recomponga cada fotograma.
76 */
77@OptIn(ExperimentalFoundationApi::class)
78@Composable
79fun OrbitLayout(
80 nodes: List<AppNode>,
81 onLaunch: (AppNode) -> Unit,
82 onLongPress: (AppNode) -> Unit,
83 onCenterTap: () -> Unit,
84 modifier: Modifier = Modifier,
85) {
86 val density = LocalDensity.current
87 val transition = rememberInfiniteTransition(label = "orbita")
88 // Sin `by`: el State se lee dentro de lambdas de dibujo, así el giro no recompone.
89 val spin = transition.animateFloat(
90 initialValue = 0f,
91 targetValue = TAU_F,
92 animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart),
93 label = "spin",
94 )
95 // El núcleo va por su cuenta: una figura a la deriva (ver Nucleo.kt). El halo
96 // y los anillos toman de ella su color y su brillo, para que todo respire igual.
97 val nucleo = rememberNucleo()
98 val forma: () -> FormaNucleo = { nucleo.value }
99
100 BoxWithConstraints(modifier.fillMaxSize()) {
101 val wPx = with(density) { maxWidth.toPx() }
102 val hPx = with(density) { maxHeight.toPx() }
103 val cx = wPx / 2f
104 val cy = hPx / 2f
105
106 val firstR = with(density) { 116.dp.toPx() }
107 val ringGap = with(density) { 86.dp.toPx() }
108 val spacing = with(density) { 76.dp.toPx() }
109 val colHalf = with(density) { 32.dp.toPx() }
110 val iconHalf = with(density) { 27.dp.toPx() }
111
112 val orbitas = rememberOrbitas(nodes, firstR, ringGap, spacing)
113 val rings = orbitas.rings
114
115 // Halo del núcleo (mucho alcance, muy difuminado) + anillos orbitales.
116 Canvas(Modifier.fillMaxSize()) {
117 val f = forma()
118 val c = colorDelCiclo(f.tinte)
119 val b = f.brillo
120 // El halo crece y mengua con la figura: cuanto más abierta y más viva, más lejos llega.
121 val halo = min(wPx, hPx) * (0.46f + 0.16f * f.radio + 0.10f * b)
122 drawCircle(
123 brush = Brush.radialGradient(
124 0.00f to c.copy(alpha = 0.24f * b),
125 0.16f to c.copy(alpha = 0.125f * b),
126 0.38f to c.copy(alpha = 0.060f * b),
127 0.66f to c.copy(alpha = 0.020f * b),
128 1.00f to Color.Transparent,
129 center = Offset(cx, cy),
130 radius = halo,
131 ),
132 radius = halo,
133 center = Offset(cx, cy),
134 blendMode = BlendMode.Plus,
135 )
136 for (r in 0 until rings) {
137 // Los anillos se tiñen a medias con el color vivo del núcleo.
138 drawCircle(
139 color = lerp(ringColors[r % ringColors.size], c, 0.5f)
140 .copy(alpha = 0.09f + 0.07f * b),
141 radius = firstR + r * ringGap,
142 center = Offset(cx, cy),
143 style = Stroke(width = 1.2f),
144 )
145 }
146 }
147
148 // Núcleo: toque para buscar en el rizoma.
149 Nucleo(
150 modifier = Modifier.align(Alignment.Center),
151 forma = forma,
152 onTap = onCenterTap,
153 )
154
155 // Nodos en órbita. Si una casilla cambia (se instala o se expulsa una app),
156 // el icono viaja hasta ella con un muelle lento: nunca reaparece en otro sitio.
157 nodes.forEach { node ->
158 val slot = orbitas.slots[node.key] ?: return@forEach
159 key(node.key) {
160 val angle = animateFloatAsState(
161 targetValue = slot.angle,
162 animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
163 label = "ang",
164 )
165 val radius = animateFloatAsState(
166 targetValue = slot.radius,
167 animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
168 label = "rad",
169 )
170 // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo.
171 val dir = if (slot.ring % 2 == 0) 1f else -1f
172 NodeItem(
173 node = node,
174 modifier = Modifier
175 .align(Alignment.TopStart)
176 .graphicsLayer {
177 val a = angle.value + spin.value * dir
178 translationX = cx + radius.value * cos(a) - colHalf
179 translationY = cy + radius.value * sin(a) - iconHalf
180 },
181 onLaunch = onLaunch,
182 onLongPress = onLongPress,
183 )
184 }
185 }
186 }
187}
188
189/**
190 * Casillas estables. El orden solo se recalcula cuando cambia la *firma* del átomo:
191 * qué apps hay, cuáles están fijadas y cuáles alejadas. El calor sube al usar una app,
192 * pero no mueve nada por su cuenta — el átomo se reordena cuando tú actúas sobre él,
193 * nunca a tus espaldas mientras miras la ronda. Y aun entonces, cada icono viaja a su
194 * nueva casilla con un muelle lento: nunca reaparece de golpe en otro sitio.
195 */
196@Composable
197private fun rememberOrbitas(
198 nodes: List<AppNode>,
199 firstR: Float,
200 ringGap: Float,
201 spacing: Float,
202): Orbitas {
203 val anguloPrevio = remember { HashMap<String, Float>() }
204 val firma = nodes.joinToString("|") { "${it.key}#${if (it.pinned) 1 else 0}#${it.penalty}" }
205 return remember(firma, firstR, ringGap, spacing) {
206 val slots = HashMap<String, Slot>(nodes.size)
207 var rings = 0
208 computePlacements(nodes.map { it.key }, firstR, ringGap, spacing).forEach { (clave, slot) ->
209 // El ángulo se "desenrolla" respecto al anterior para que la animación
210 // tome el camino corto y no dé una vuelta entera de más.
211 val previo = anguloPrevio[clave]
212 val angulo = if (previo == null) slot.angle else desenrollar(previo, slot.angle)
213 anguloPrevio[clave] = angulo
214 slots[clave] = slot.copy(angle = angulo)
215 rings = max(rings, slot.ring + 1)
216 }
217 anguloPrevio.keys.retainAll(slots.keys)
218 Orbitas(slots, rings)
219 }
220}
221
222private fun desenrollar(previo: Float, destino: Float): Float {
223 var a = destino
224 while (a - previo > TAU_F / 2f) a -= TAU_F
225 while (previo - a > TAU_F / 2f) a += TAU_F
226 return a
227}
228
229@OptIn(ExperimentalFoundationApi::class)
230@Composable
231private fun NodeItem(
232 node: AppNode,
233 modifier: Modifier,
234 onLaunch: (AppNode) -> Unit,
235 onLongPress: (AppNode) -> Unit,
236) {
237 Column(
238 modifier = modifier.width(64.dp),
239 horizontalAlignment = Alignment.CenterHorizontally,
240 ) {
241 val iconMod = Modifier
242 .size(54.dp)
243 .clip(CircleShape)
244 .combinedClickable(
245 onClick = { onLaunch(node) },
246 onLongClick = { onLongPress(node) },
247 )
248 .then(
249 if (node.pinned) Modifier.border(1.5.dp, NeonRosa, CircleShape) else Modifier
250 )
251 Image(
252 bitmap = node.icon,
253 contentDescription = node.label,
254 modifier = iconMod,
255 )
256 Text(
257 text = node.label,
258 color = Tenue,
259 fontSize = 10.sp,
260 maxLines = 1,
261 overflow = TextOverflow.Ellipsis,
262 textAlign = TextAlign.Center,
263 )
264 }
265}
266
267/**
268 * Reparte las claves (ya ordenadas) en anillos. Cada anillo crece hacia afuera y su
269 * capacidad depende de su circunferencia, así nada se amontona.
270 */
271private fun computePlacements(
272 keys: List<String>,
273 firstR: Float,
274 ringGap: Float,
275 spacing: Float,
276): List<Pair<String, Slot>> {
277 if (keys.isEmpty()) return emptyList()
278 val result = ArrayList<Pair<String, Slot>>(keys.size)
279 var index = 0
280 var ring = 0
281 while (index < keys.size) {
282 val radius = firstR + ring * ringGap
283 val circumference = 2.0 * PI * radius
284 val capacity = max(4, floor(circumference / spacing).toInt())
285 val count = min(capacity, keys.size - index)
286 val base = ring * 0.5f
287 for (j in 0 until count) {
288 val angle = base + TAU_F * j / count
289 result.add(keys[index + j] to Slot(ring, angle, radius))
290 }
291 index += count
292 ring++
293 }
294 return result
295}