tawasuyu

sergio/humanoid

HTML 37Markdown 1TOML 10 Definitions

Órbita: alejar + fondo dinámico · Trazo: aprendizaje con umbral y bilingüe

Órbita:
- Acción "alejar" en el menú: empuja a órbitas externas un nodo que se acercó
  demasiado (penalización persistida sobre su calor; vuelve si se reusa bastante).
- Fondo dinámico: manchas de luz neón (verde/rosa/cian) que derivan en
  trayectorias de Lissajous y laten, mezcladas de forma aditiva sobre el vacío.

Trazo:
- Aprendizaje "bueno": las palabras del diccionario se refuerzan al instante,
  pero una palabra nueva entra a sugerencias solo tras escribirse 2 veces
  (los typos de una vez no contaminan).
- Sugerencias bilingües: diccionarios es + en cargados juntos; salen en el
  idioma del prefijo que se escribe. Sin autocorrección (solo capitaliza).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

8ab12b6800f7b28dfd3aa1a0ee97dff7b1ca41b7 · by sergio · (4 months ago) · parent 8380e44a0 · browse the tree at this commit

Semantic delta

No definition changed: this change is text, documentation or data.

7 files · +348 −17

diff --git a/keyboard/src/main/assets/dict_en.txt b/keyboard/src/main/assets/dict_en.txt
new file mode 100644
index 0000000..8d011b4
--- /dev/null
+++ b/keyboard/src/main/assets/dict_en.txt
@@ -0,0 +1,192 @@
+the
+be
+to
+of
+and
+in
+that
+have
+it
+for
+not
+on
+with
+he
+as
+you
+do
+at
+this
+but
+his
+by
+from
+they
+we
+say
+her
+she
+or
+an
+will
+my
+one
+all
+would
+there
+their
+what
+so
+up
+out
+if
+about
+who
+get
+which
+go
+me
+when
+make
+can
+like
+time
+no
+just
+him
+know
+take
+people
+into
+year
+your
+good
+some
+could
+them
+see
+other
+than
+then
+now
+look
+only
+come
+its
+over
+think
+also
+back
+after
+use
+two
+how
+our
+work
+first
+well
+way
+even
+new
+want
+because
+any
+these
+give
+day
+most
+us
+is
+are
+was
+were
+been
+has
+had
+many
+much
+where
+why
+here
+thing
+great
+little
+world
+still
+should
+need
+something
+nothing
+everything
+someone
+everyone
+always
+never
+again
+really
+right
+left
+through
+before
+between
+under
+around
+while
+during
+without
+within
+together
+another
+each
+every
+same
+different
+important
+possible
+better
+best
+small
+large
+high
+long
+next
+last
+early
+later
+today
+tomorrow
+yesterday
+hello
+thanks
+please
+sorry
+maybe
+sure
+okay
+yeah
+love
+home
+house
+water
+money
+name
+number
+example
+question
+answer
+problem
+people
+friend
+family
+school
+city
+country
+phone
+message
+email
+please
+because
+beautiful
+amazing
+awesome
+wonderful
diff --git a/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt b/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
index 1d25124..13f8de1 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
@@ -15,30 +15,40 @@ import java.util.concurrent.ConcurrentHashMap
 private val Context.trazoLearnedStore: DataStore<Preferences> by preferencesDataStore(name = "trazo_learned")
 
 /**
- * Predicción por prefijo. Mezcla un diccionario base (assets) con las palabras que el
- * usuario escribe (aprendidas y persistidas). El peso ordena las sugerencias.
+ * Predicción por prefijo, bilingüe (es + en). Dos fuentes:
+ *  - [base]: diccionarios empaquetados (assets), peso fijo por frecuencia.
+ *  - [learned]: palabras que el usuario escribe, contadas y persistidas.
+ *
+ * Mecanismo "buenas sugerencias": una palabra del diccionario se refuerza al instante,
+ * pero una palabra NUEVA solo entra a las sugerencias tras escribirse [PROMOTE] veces,
+ * así los errores escritos una sola vez nunca contaminan. Las sugerencias salen en el
+ * idioma del prefijo que escribes (ambos diccionarios conviven).
  */
 class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
 
     private val appContext = context.applicationContext
     private val store = appContext.trazoLearnedStore
-    private val freq = ConcurrentHashMap<String, Int>()
+
+    private val base = ConcurrentHashMap<String, Int>()
+    private val learned = ConcurrentHashMap<String, Int>()
 
     init {
         scope.launch(Dispatchers.IO) {
-            loadBase()
+            loadFile("dict_es.txt")
+            loadFile("dict_en.txt")
             loadLearned()
         }
     }
 
-    private fun loadBase() {
+    private fun loadFile(name: String) {
         runCatching {
-            appContext.assets.open("dict_es.txt").bufferedReader().useLines { lines ->
+            appContext.assets.open(name).bufferedReader().useLines { lines ->
                 var i = 0
                 lines.forEach { line ->
                     val w = line.trim().lowercase()
                     if (w.isNotEmpty()) {
-                        freq.merge(w, maxOf(1, BASE_TOP - i)) { a, b -> maxOf(a, b) }
+                        val weight = maxOf(1, BASE_TOP - i)
+                        base.merge(w, weight) { a, b -> maxOf(a, b) }
                         i++
                     }
                 }
@@ -50,30 +60,38 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
         runCatching {
             store.data.first().asMap().forEach { (key, value) ->
                 if (key.name.startsWith(W) && value is Int) {
-                    val w = key.name.removePrefix(W)
-                    freq.merge(w, value * LEARN_INC) { a, b -> a + b }
+                    learned[key.name.removePrefix(W)] = value
                 }
             }
         }
     }
 
-    /** Completa el prefijo con las palabras de mayor peso (más cortas primero a igual peso). */
+    /** Completa el prefijo; mayor peso primero, a igual peso la más corta. */
     fun suggest(prefix: String, limit: Int = 3): List<String> {
         val p = prefix.trim().lowercase()
         if (p.isEmpty()) return emptyList()
-        return freq.entries.asSequence()
-            .filter { it.key.length > p.length && it.key.startsWith(p) }
+
+        val candidates = HashMap<String, Int>()
+        base.forEach { (w, weight) ->
+            if (w.length > p.length && w.startsWith(p)) candidates[w] = weight
+        }
+        learned.forEach { (w, count) ->
+            // Solo palabras nuevas ya "promovidas"; las del diccionario suman refuerzo.
+            if (w.length > p.length && w.startsWith(p) && (count >= PROMOTE || base.containsKey(w))) {
+                candidates[w] = (candidates[w] ?: 0) + count * LEARN_INC
+            }
+        }
+        return candidates.entries
             .sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key.length })
             .map { it.key }
             .take(limit)
-            .toList()
     }
 
-    /** Aprende/refuerza una palabra escrita por el usuario. */
+    /** Aprende/refuerza una palabra escrita por el usuario (se promueve sola al llegar a PROMOTE). */
     fun learn(raw: String) {
         val w = raw.trim().lowercase()
         if (w.length < 2 || !w.all { it.isLetter() }) return
-        freq.merge(w, LEARN_INC) { a, _ -> a + LEARN_INC }
+        learned.merge(w, 1) { a, _ -> a + 1 }
         scope.launch(Dispatchers.IO) {
             runCatching {
                 store.edit { prefs ->
@@ -87,6 +105,7 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
     private companion object {
         const val BASE_TOP = 5000
         const val LEARN_INC = 400
+        const val PROMOTE = 2
         const val W = "w:"
     }
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
index 5fd4fbb..56c49ad 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
@@ -32,6 +32,17 @@ class UsageStore(context: Context) {
 
     fun pinned(): Flow<Set<String>> = store.data.map { it[PINNED] ?: emptySet() }
 
+    /** Penalización manual de órbita: el usuario "aleja" un nodo que se acercó demasiado. */
+    fun penalties(): Flow<Map<String, Int>> = store.data.map { prefs ->
+        buildMap {
+            prefs.asMap().forEach { (key, value) ->
+                if (key.name.startsWith(PENALTY_PREFIX) && value is Int) {
+                    put(key.name.removePrefix(PENALTY_PREFIX), value)
+                }
+            }
+        }
+    }
+
     suspend fun record(nodeKey: String) {
         store.edit { prefs ->
             val k = intPreferencesKey(COUNT_PREFIX + nodeKey)
@@ -47,8 +58,20 @@ class UsageStore(context: Context) {
         }
     }
 
+    /**
+     * Aleja un nodo: fija una penalización por encima de su calor actual, de modo que
+     * cae a órbitas externas. Volverá a acercarse solo si se vuelve a usar bastante.
+     */
+    suspend fun pushAway(nodeKey: String, currentCount: Int) {
+        store.edit { prefs ->
+            prefs[intPreferencesKey(PENALTY_PREFIX + nodeKey)] = currentCount + PUSH_BUFFER
+        }
+    }
+
     private companion object {
         const val COUNT_PREFIX = "c:"
+        const val PENALTY_PREFIX = "p:"
+        const val PUSH_BUFFER = 8
         val PINNED = stringSetPreferencesKey("pinned")
     }
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/DynamicBackground.kt b/launcher/src/main/java/com/rizoma/orbita/ui/DynamicBackground.kt
new file mode 100644
index 0000000..f6390e3
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/DynamicBackground.kt
@@ -0,0 +1,84 @@
+package com.rizoma.orbita.ui
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import com.rizoma.orbita.ui.theme.NeonCian
+import com.rizoma.orbita.ui.theme.NeonRosa
+import com.rizoma.orbita.ui.theme.NeonVerde
+import com.rizoma.orbita.ui.theme.Vacio
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+private data class Blob(
+    val color: Color,
+    val periodMs: Int,
+    val ax: Float, val ay: Float,   // amplitud relativa del vaivén
+    val ox: Float, val oy: Float,   // origen relativo
+    val freqY: Float,               // relación de frecuencia vertical (Lissajous)
+    val phase: Float,
+)
+
+private val blobs = listOf(
+    Blob(NeonVerde, 49_000, 0.34f, 0.30f, 0.42f, 0.40f, 1.30f, 0f),
+    Blob(NeonRosa, 67_000, 0.38f, 0.26f, 0.58f, 0.62f, 0.80f, 2.1f),
+    Blob(NeonCian, 83_000, 0.30f, 0.34f, 0.50f, 0.30f, 1.70f, 4.0f),
+)
+
+/**
+ * Fondo dinámico congruente con el rizoma: manchas de luz neón que derivan en
+ * trayectorias de Lissajous y laten suavemente, mezcladas de forma aditiva sobre el vacío.
+ */
+@Composable
+fun DynamicBackground(modifier: Modifier = Modifier) {
+    val transition = rememberInfiniteTransition(label = "fondo")
+    val tau = (2 * PI).toFloat()
+    // Una fase por mancha (llamadas directas: animateFloat es @Composable, no cabe en un map/función local).
+    val p0 by transition.animateFloat(
+        0f, tau, infiniteRepeatable(tween(blobs[0].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p0",
+    )
+    val p1 by transition.animateFloat(
+        0f, tau, infiniteRepeatable(tween(blobs[1].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p1",
+    )
+    val p2 by transition.animateFloat(
+        0f, tau, infiniteRepeatable(tween(blobs[2].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p2",
+    )
+    val phases = floatArrayOf(p0, p1, p2)
+
+    Canvas(modifier.fillMaxSize()) {
+        drawRect(Vacio)
+        val w = size.width
+        val h = size.height
+        val base = maxOf(w, h)
+        blobs.forEachIndexed { i, blob ->
+            val t = phases[i] + blob.phase
+            val cx = w * (blob.ox + blob.ax * cos(t))
+            val cy = h * (blob.oy + blob.ay * sin(t * blob.freqY))
+            val pulse = 0.82f + 0.18f * sin(t * 1.7f)
+            val radius = base * 0.62f * pulse
+            drawCircle(
+                brush = Brush.radialGradient(
+                    colors = listOf(blob.color.copy(alpha = 0.16f), Color.Transparent),
+                    center = Offset(cx, cy),
+                    radius = radius,
+                ),
+                radius = radius,
+                center = Offset(cx, cy),
+                blendMode = BlendMode.Plus,
+            )
+        }
+    }
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
index 0dfb859..8797fc4 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
@@ -65,6 +65,7 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
     }
 
     Box(Modifier.fillMaxSize().background(Vacio)) {
+        DynamicBackground()
         OrbitLayout(
             nodes = nodes,
             onLaunch = vm::launch,
@@ -87,6 +88,7 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
             NodeMenu(
                 node = node,
                 onPin = { vm.togglePin(node); menuNode = null },
+                onPush = { vm.pushAway(node); menuNode = null },
                 onInfo = { vm.openInfo(node); menuNode = null },
                 onUninstall = { vm.uninstall(node); menuNode = null },
                 onDismiss = { menuNode = null },
@@ -183,6 +185,7 @@ private fun SearchOverlay(
 private fun NodeMenu(
     node: AppNode,
     onPin: () -> Unit,
+    onPush: () -> Unit,
     onInfo: () -> Unit,
     onUninstall: () -> Unit,
     onDismiss: () -> Unit,
@@ -207,6 +210,7 @@ private fun NodeMenu(
                     accent = NeonRosa,
                     onClick = onPin,
                 )
+                MenuRow(label = stringResource(R.string.menu_push), accent = NeonVerde, onClick = onPush)
                 MenuRow(label = stringResource(R.string.menu_info), accent = NeonCian, onClick = onInfo)
                 MenuRow(label = stringResource(R.string.menu_uninstall), accent = NeonRosa, onClick = onUninstall)
             }
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
index 36e2a45..3730a6e 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
@@ -33,15 +33,18 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         rawApps,
         usage.counts(),
         usage.pinned(),
-    ) { apps, counts, pinned ->
+        usage.penalties(),
+    ) { apps, counts, pinned, penalties ->
         apps.map { node ->
             node.copy(
                 score = (counts[node.key] ?: 0).toFloat(),
                 pinned = node.key in pinned,
             )
         }.sortedWith(
+            // El orden define la órbita: fijados al centro, luego por calor efectivo
+            // (calor menos penalización por "alejar"), luego alfabético.
             compareByDescending<AppNode> { it.pinned }
-                .thenByDescending { it.score }
+                .thenByDescending { it.score - (penalties[it.key] ?: 0).toFloat() }
                 .thenBy { it.label.lowercase() }
         )
     }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@@ -80,6 +83,11 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         viewModelScope.launch { usage.setPinned(node.key, !node.pinned) }
     }
 
+    /** Aleja el nodo a órbitas externas (usa su calor actual como referencia). */
+    fun pushAway(node: AppNode) {
+        viewModelScope.launch { usage.pushAway(node.key, node.score.toInt()) }
+    }
+
     fun openInfo(node: AppNode) {
         val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
             .setData(Uri.fromParts("package", node.packageName, null))
diff --git a/launcher/src/main/res/values/strings.xml b/launcher/src/main/res/values/strings.xml
index 4874927..9cb4aa4 100644
--- a/launcher/src/main/res/values/strings.xml
+++ b/launcher/src/main/res/values/strings.xml
@@ -4,6 +4,7 @@
     <string name="search_hint">buscar en el rizoma…</string>
     <string name="menu_pin">fijar</string>
     <string name="menu_unpin">soltar</string>
+    <string name="menu_push">alejar</string>
     <string name="menu_info">info</string>
     <string name="menu_uninstall">quitar</string>
 </resources>