tawasuyu

sergio/humanoid

HTML 38Markdown 1TOML 10 Definiciones

Trazo: pulido v0.4 — háptica, palabra siguiente, emojis y ajustes

- Háptica: ime/Haptics (Vibrator + VibrationEffect, permiso VIBRATE) con
  intensidad suave/medio/fuerte; el KeyboardController la dispara en cada
  acción (cursor con tick suave).
- Palabra siguiente: SuggestionEngine aprende bigramas (b:prev>next,
  persistidos) y predice tras espacio/sugerencia, encadenable; filtra a
  palabras conocidas para no propagar erratas. Diccionarios separados es/en.
- Emojis: nueva Layer.EMOJIS con catálogo (model/Emoji) y panel desplazable
  (ui/EmojiGrid, recientes incluidos); tecla 😊 en la fila de funciones.
- Ajustes: data/TrazoSettings (DataStore reactivo, estado Compose compartido
  entre IME y Activity) + pantalla en MainActivity con toggles de vibración
  e intensidad, doble-espacio→". " e idiomas de predicción es/en.

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

b021d6829a602218866114f86e2350d80f0500e2 · por sergio · (hace 4 meses) · padre 608145b4b · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

10 archivos · +541 −71

diff --git a/keyboard/src/main/AndroidManifest.xml b/keyboard/src/main/AndroidManifest.xml
index 436fe0b..deac597 100644
--- a/keyboard/src/main/AndroidManifest.xml
+++ b/keyboard/src/main/AndroidManifest.xml
@@ -1,6 +1,9 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
+    <!-- Retorno háptico al teclear. -->
+    <uses-permission android:name="android.permission.VIBRATE" />
+
     <application
         android:label="@string/app_name"
         android:icon="@mipmap/ic_launcher"
diff --git a/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt b/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt
index d16c93d..6a9d740 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/MainActivity.kt
@@ -11,22 +11,31 @@ import androidx.compose.foundation.background
 import androidx.compose.foundation.border
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.core.view.WindowCompat
+import androidx.lifecycle.lifecycleScope
+import com.rizoma.trazo.data.HapticLevel
+import com.rizoma.trazo.data.TrazoSettings
 import com.rizoma.trazo.ui.theme.NeonCian
 import com.rizoma.trazo.ui.theme.NeonRosa
 import com.rizoma.trazo.ui.theme.NeonVerde
@@ -35,17 +44,20 @@ import com.rizoma.trazo.ui.theme.Tenue
 import com.rizoma.trazo.ui.theme.TrazoTheme
 import com.rizoma.trazo.ui.theme.Vacio
 
-/** Pantalla mínima para activar Trazo y elegirlo como teclado. */
+/** Pantalla para activar Trazo, elegirlo como teclado y ajustar sus preferencias. */
 class MainActivity : ComponentActivity() {
+
+    private lateinit var settings: TrazoSettings
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
+        settings = TrazoSettings(this, lifecycleScope)
         WindowCompat.setDecorFitsSystemWindows(window, false)
         setContent {
             TrazoTheme {
                 Setup(
-                    onActivar = {
-                        startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS))
-                    },
+                    settings = settings,
+                    onActivar = { startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS)) },
                     onElegir = {
                         (getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
                             .showInputMethodPicker()
@@ -57,30 +69,54 @@ class MainActivity : ComponentActivity() {
 }
 
 @Composable
-private fun Setup(onActivar: () -> Unit, onElegir: () -> Unit) {
+private fun Setup(settings: TrazoSettings, onActivar: () -> Unit, onElegir: () -> Unit) {
     Column(
         Modifier
             .fillMaxSize()
             .background(Vacio)
-            .padding(horizontal = 32.dp),
-        verticalArrangement = Arrangement.Center,
+            .verticalScroll(rememberScrollState())
+            .padding(horizontal = 32.dp, vertical = 56.dp),
     ) {
         Text("Trazo", color = NeonVerde, fontSize = 44.sp, fontWeight = FontWeight.Light)
         Spacer(Modifier.height(8.dp))
-        Text(
-            "teclado de trazos — un toque, ocho gestos.",
-            color = Tenue,
-            fontSize = 15.sp,
-        )
-        Spacer(Modifier.height(40.dp))
+        Text("teclado de trazos — un toque, ocho gestos.", color = Tenue, fontSize = 15.sp)
+        Spacer(Modifier.height(36.dp))
+
         Paso("1", "activa Trazo en ajustes del sistema", NeonCian, onActivar)
         Spacer(Modifier.height(16.dp))
         Paso("2", "elígelo como teclado actual", NeonRosa, onElegir)
+
+        Spacer(Modifier.height(40.dp))
+        Seccion("ajustes")
+        Spacer(Modifier.height(12.dp))
+
+        Toggle("vibración al teclear", settings.haptics, NeonCian) { settings.setHaptics(it) }
+        if (settings.haptics) {
+            Spacer(Modifier.height(10.dp))
+            NivelHaptico(settings.hapticLevel) { settings.setHapticLevel(it) }
+        }
+
+        Spacer(Modifier.height(10.dp))
+        Toggle("doble espacio → punto", settings.doubleSpacePeriod, NeonVerde) {
+            settings.setDoubleSpacePeriod(it)
+        }
+
+        Spacer(Modifier.height(24.dp))
+        Seccion("idiomas de predicción")
+        Spacer(Modifier.height(12.dp))
+        Toggle("español", settings.spanish, NeonRosa) { settings.setSpanish(it) }
+        Spacer(Modifier.height(10.dp))
+        Toggle("inglés", settings.english, NeonCian) { settings.setEnglish(it) }
     }
 }
 
 @Composable
-private fun Paso(num: String, label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
+private fun Seccion(titulo: String) {
+    Text(titulo, color = Tenue, fontSize = 13.sp, fontWeight = FontWeight.Medium)
+}
+
+@Composable
+private fun Paso(num: String, label: String, accent: Color, onClick: () -> Unit) {
     Column(
         Modifier
             .fillMaxWidth()
@@ -92,6 +128,74 @@ private fun Paso(num: String, label: String, accent: androidx.compose.ui.graphic
     ) {
         Text(num, color = accent, fontSize = 13.sp, fontWeight = FontWeight.Bold)
         Spacer(Modifier.height(6.dp))
-        Text(label, color = androidx.compose.ui.graphics.Color.White, fontSize = 17.sp)
+        Text(label, color = Color.White, fontSize = 17.sp)
+    }
+}
+
+@Composable
+private fun Toggle(label: String, value: Boolean, accent: Color, onChange: (Boolean) -> Unit) {
+    Row(
+        Modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(14.dp))
+            .background(Tecla)
+            .clickable { onChange(!value) }
+            .padding(horizontal = 18.dp, vertical = 16.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Text(label, color = Color.White, fontSize = 16.sp, modifier = Modifier.weight(1f))
+        Pill(value, accent)
+    }
+}
+
+/** Indicador tipo interruptor: relleno y a la derecha cuando está activo. */
+@Composable
+private fun Pill(on: Boolean, accent: Color) {
+    Box(
+        Modifier
+            .width(46.dp)
+            .height(26.dp)
+            .clip(RoundedCornerShape(13.dp))
+            .background(if (on) accent.copy(alpha = 0.28f) else Color(0xFF1B1B26))
+            .border(1.dp, if (on) accent else Tenue, RoundedCornerShape(13.dp)),
+        contentAlignment = if (on) Alignment.CenterEnd else Alignment.CenterStart,
+    ) {
+        Box(
+            Modifier
+                .padding(3.dp)
+                .width(20.dp)
+                .height(20.dp)
+                .clip(RoundedCornerShape(10.dp))
+                .background(if (on) accent else Tenue),
+        )
+    }
+}
+
+@Composable
+private fun NivelHaptico(level: HapticLevel, onSelect: (HapticLevel) -> Unit) {
+    Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+        HapticLevel.entries.forEach { opt ->
+            val selected = opt == level
+            Box(
+                Modifier
+                    .weight(1f)
+                    .clip(RoundedCornerShape(12.dp))
+                    .background(if (selected) NeonCian.copy(alpha = 0.18f) else Tecla)
+                    .border(
+                        1.dp,
+                        if (selected) NeonCian else Tenue.copy(alpha = 0.4f),
+                        RoundedCornerShape(12.dp),
+                    )
+                    .clickable { onSelect(opt) }
+                    .padding(vertical = 12.dp),
+                contentAlignment = Alignment.Center,
+            ) {
+                Text(
+                    text = opt.name.lowercase(),
+                    color = if (selected) NeonCian else Tenue,
+                    fontSize = 14.sp,
+                )
+            }
+        }
     }
 }
diff --git a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
index 3d2c656..536c387 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
@@ -18,6 +18,8 @@ import androidx.savedstate.SavedStateRegistryController
 import androidx.savedstate.SavedStateRegistryOwner
 import androidx.savedstate.setViewTreeSavedStateRegistryOwner
 import com.rizoma.trazo.data.SuggestionEngine
+import com.rizoma.trazo.data.TrazoSettings
+import com.rizoma.trazo.ime.Haptics
 import com.rizoma.trazo.ime.ImeHost
 import com.rizoma.trazo.ime.KeyboardController
 import com.rizoma.trazo.ui.KeyboardScreen
@@ -95,13 +97,15 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
     }
 
     private lateinit var engine: SuggestionEngine
+    private lateinit var settings: TrazoSettings
     private lateinit var controller: KeyboardController
 
     override fun onCreate() {
         super.onCreate()
         savedStateController.performRestore(null)
         engine = SuggestionEngine(this, scope)
-        controller = KeyboardController(host, engine)
+        settings = TrazoSettings(this, scope)
+        controller = KeyboardController(host, engine, settings, Haptics(this))
         lifecycleRegistry.currentState = Lifecycle.State.CREATED
     }
 
@@ -120,7 +124,7 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
             setViewTreeSavedStateRegistryOwner(this@TrazoImeService)
             setContent {
                 TrazoTheme {
-                    KeyboardScreen(controller)
+                    KeyboardScreen(controller, settings)
                 }
             }
         }
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 13f8de1..557cc2d 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
@@ -29,18 +29,22 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
     private val appContext = context.applicationContext
     private val store = appContext.trazoLearnedStore
 
-    private val base = ConcurrentHashMap<String, Int>()
+    // Diccionarios separados por idioma para poder activarlos/desactivarlos.
+    private val baseEs = ConcurrentHashMap<String, Int>()
+    private val baseEn = ConcurrentHashMap<String, Int>()
     private val learned = ConcurrentHashMap<String, Int>()
+    // Bigramas: palabra previa -> {palabra siguiente -> veces}. Alimenta "palabra siguiente".
+    private val bigrams = ConcurrentHashMap<String, ConcurrentHashMap<String, Int>>()
 
     init {
         scope.launch(Dispatchers.IO) {
-            loadFile("dict_es.txt")
-            loadFile("dict_en.txt")
-            loadLearned()
+            loadFile("dict_es.txt", baseEs)
+            loadFile("dict_en.txt", baseEn)
+            loadPersisted()
         }
     }
 
-    private fun loadFile(name: String) {
+    private fun loadFile(name: String, into: ConcurrentHashMap<String, Int>) {
         runCatching {
             appContext.assets.open(name).bufferedReader().useLines { lines ->
                 var i = 0
@@ -48,7 +52,7 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
                     val w = line.trim().lowercase()
                     if (w.isNotEmpty()) {
                         val weight = maxOf(1, BASE_TOP - i)
-                        base.merge(w, weight) { a, b -> maxOf(a, b) }
+                        into.merge(w, weight) { a, b -> maxOf(a, b) }
                         i++
                     }
                 }
@@ -56,28 +60,47 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
         }
     }
 
-    private suspend fun loadLearned() {
+    private suspend fun loadPersisted() {
         runCatching {
             store.data.first().asMap().forEach { (key, value) ->
-                if (key.name.startsWith(W) && value is Int) {
-                    learned[key.name.removePrefix(W)] = value
+                val name = key.name
+                if (value !is Int) return@forEach
+                when {
+                    name.startsWith(W) -> learned[name.removePrefix(W)] = value
+                    name.startsWith(B) -> {
+                        val rest = name.removePrefix(B)
+                        val sep = rest.indexOf(SEP)
+                        if (sep > 0 && sep < rest.length - 1) {
+                            val prev = rest.substring(0, sep)
+                            val next = rest.substring(sep + 1)
+                            bigrams.getOrPut(prev) { ConcurrentHashMap() }[next] = value
+                        }
+                    }
                 }
             }
         }
     }
 
-    /** Completa el prefijo; mayor peso primero, a igual peso la más corta. */
-    fun suggest(prefix: String, limit: Int = 3): List<String> {
+    private fun inBase(w: String) = baseEs.containsKey(w) || baseEn.containsKey(w)
+
+    /** Completa el prefijo en los idiomas activos; mayor peso primero, a igual peso la más corta. */
+    fun suggest(prefix: String, useEs: Boolean, useEn: Boolean, limit: Int = 3): List<String> {
         val p = prefix.trim().lowercase()
         if (p.isEmpty()) return emptyList()
 
         val candidates = HashMap<String, Int>()
-        base.forEach { (w, weight) ->
-            if (w.length > p.length && w.startsWith(p)) candidates[w] = weight
+        fun scan(map: ConcurrentHashMap<String, Int>) {
+            map.forEach { (w, weight) ->
+                if (w.length > p.length && w.startsWith(p)) {
+                    candidates[w] = maxOf(candidates[w] ?: 0, weight)
+                }
+            }
         }
+        if (useEs) scan(baseEs)
+        if (useEn) scan(baseEn)
         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))) {
+            if (w.length > p.length && w.startsWith(p) && (count >= PROMOTE || inBase(w))) {
                 candidates[w] = (candidates[w] ?: 0) + count * LEARN_INC
             }
         }
@@ -87,10 +110,21 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
             .take(limit)
     }
 
+    /** Palabras más probables tras [prev], según los bigramas aprendidos. */
+    fun nextWords(prev: String, limit: Int = 3): List<String> {
+        val a = clean(prev) ?: return emptyList()
+        val map = bigrams[a] ?: return emptyList()
+        return map.entries
+            // Evita propagar erratas: la siguiente debe ser palabra conocida.
+            .filter { inBase(it.key) || (learned[it.key] ?: 0) >= PROMOTE }
+            .sortedByDescending { it.value }
+            .map { it.key }
+            .take(limit)
+    }
+
     /** 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
+        val w = clean(raw) ?: return
         learned.merge(w, 1) { a, _ -> a + 1 }
         scope.launch(Dispatchers.IO) {
             runCatching {
@@ -102,10 +136,32 @@ class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
         }
     }
 
+    /** Registra que [next] siguió a [prev] (para la predicción de palabra siguiente). */
+    fun learnBigram(prev: String, next: String) {
+        val a = clean(prev) ?: return
+        val b = clean(next) ?: return
+        bigrams.getOrPut(a) { ConcurrentHashMap() }.merge(b, 1) { x, _ -> x + 1 }
+        scope.launch(Dispatchers.IO) {
+            runCatching {
+                store.edit { prefs ->
+                    val key = intPreferencesKey(B + a + SEP + b)
+                    prefs[key] = (prefs[key] ?: 0) + 1
+                }
+            }
+        }
+    }
+
+    private fun clean(raw: String): String? {
+        val w = raw.trim().lowercase()
+        return if (w.length >= 2 && w.all { it.isLetter() }) w else null
+    }
+
     private companion object {
         const val BASE_TOP = 5000
         const val LEARN_INC = 400
         const val PROMOTE = 2
         const val W = "w:"
+        const val B = "b:"
+        const val SEP = '>'   // separador prev/next; seguro porque las palabras son solo letras
     }
 }
diff --git a/keyboard/src/main/java/com/rizoma/trazo/data/TrazoSettings.kt b/keyboard/src/main/java/com/rizoma/trazo/data/TrazoSettings.kt
new file mode 100644
index 0000000..dd3c0da
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/data/TrazoSettings.kt
@@ -0,0 +1,78 @@
+package com.rizoma.trazo.data
+
+import android.content.Context
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/** Intensidad del retorno háptico al teclear. */
+enum class HapticLevel { SUAVE, MEDIO, FUERTE }
+
+private val Context.trazoSettingsStore: DataStore<Preferences> by preferencesDataStore(name = "trazo_settings")
+
+/**
+ * Preferencias de Trazo, persistidas en DataStore y expuestas como estado de Compose.
+ * Un único colector mantiene en sincronía cualquier instancia (la del IME y la de la
+ * pantalla de ajustes), así que tocar un toggle se refleja al instante en el teclado.
+ */
+class TrazoSettings(context: Context, private val scope: CoroutineScope) {
+
+    private val store = context.applicationContext.trazoSettingsStore
+
+    var haptics by mutableStateOf(true); private set
+    var hapticLevel by mutableStateOf(HapticLevel.MEDIO); private set
+    var doubleSpacePeriod by mutableStateOf(true); private set
+    var spanish by mutableStateOf(true); private set
+    var english by mutableStateOf(true); private set
+    var recentEmojis by mutableStateOf<List<String>>(emptyList()); private set
+
+    init {
+        scope.launch {
+            store.data.collect { p ->
+                haptics = p[HAPTICS] ?: true
+                hapticLevel = runCatching { HapticLevel.valueOf(p[HAPTIC_LEVEL] ?: "") }
+                    .getOrDefault(HapticLevel.MEDIO)
+                doubleSpacePeriod = p[DOUBLE_SPACE] ?: true
+                spanish = p[LANG_ES] ?: true
+                english = p[LANG_EN] ?: true
+                recentEmojis = p[RECENT_EMOJIS]?.split(" ")?.filter { it.isNotEmpty() } ?: emptyList()
+            }
+        }
+    }
+
+    fun setHaptics(v: Boolean) = persist { it[HAPTICS] = v }
+    fun setHapticLevel(v: HapticLevel) = persist { it[HAPTIC_LEVEL] = v.name }
+    fun setDoubleSpacePeriod(v: Boolean) = persist { it[DOUBLE_SPACE] = v }
+    fun setSpanish(v: Boolean) = persist { it[LANG_ES] = v }
+    fun setEnglish(v: Boolean) = persist { it[LANG_EN] = v }
+
+    /** Empuja un emoji a la cabeza de "recientes" (sin duplicar, acotado). */
+    fun pushRecentEmoji(e: String) {
+        val updated = (listOf(e) + recentEmojis.filter { it != e }).take(RECENTS_MAX)
+        persist { it[RECENT_EMOJIS] = updated.joinToString(" ") }
+    }
+
+    private fun persist(block: (MutablePreferences) -> Unit) {
+        scope.launch(Dispatchers.IO) { runCatching { store.edit(block) } }
+    }
+
+    private companion object {
+        val HAPTICS = booleanPreferencesKey("haptics")
+        val HAPTIC_LEVEL = stringPreferencesKey("haptic_level")
+        val DOUBLE_SPACE = booleanPreferencesKey("double_space_period")
+        val LANG_ES = booleanPreferencesKey("lang_es")
+        val LANG_EN = booleanPreferencesKey("lang_en")
+        val RECENT_EMOJIS = stringPreferencesKey("recent_emojis")
+        const val RECENTS_MAX = 24
+    }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ime/Haptics.kt b/keyboard/src/main/java/com/rizoma/trazo/ime/Haptics.kt
new file mode 100644
index 0000000..8d07e6c
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/ime/Haptics.kt
@@ -0,0 +1,40 @@
+package com.rizoma.trazo.ime
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import com.rizoma.trazo.data.HapticLevel
+
+/**
+ * Retorno háptico del teclado. Usa el Vibrator del sistema con control de amplitud
+ * cuando el dispositivo lo soporta; si no, cae a la vibración por defecto. Todo es
+ * best-effort: sin vibrador o con cualquier fallo, simplemente no hace nada.
+ */
+class Haptics(context: Context) {
+
+    private val vibrator: Vibrator? = run {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
+            vm?.defaultVibrator
+        } else {
+            @Suppress("DEPRECATION")
+            context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+        }
+    }
+
+    fun tap(level: HapticLevel) {
+        val v = vibrator ?: return
+        if (!v.hasVibrator()) return
+        val (ms, amp) = when (level) {
+            HapticLevel.SUAVE -> 8L to 70
+            HapticLevel.MEDIO -> 14L to 130
+            HapticLevel.FUERTE -> 22L to 210
+        }
+        runCatching {
+            val amplitude = if (v.hasAmplitudeControl()) amp else VibrationEffect.DEFAULT_AMPLITUDE
+            v.vibrate(VibrationEffect.createOneShot(ms, amplitude))
+        }
+    }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt b/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
index 02eabeb..7e630e9 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
@@ -3,7 +3,9 @@ package com.rizoma.trazo.ime
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
+import com.rizoma.trazo.data.HapticLevel
 import com.rizoma.trazo.data.SuggestionEngine
+import com.rizoma.trazo.data.TrazoSettings
 import com.rizoma.trazo.model.Layouts
 
 /** Puente con el editor real. Lo implementa el InputMethodService. */
@@ -20,7 +22,7 @@ interface ImeHost {
 }
 
 enum class ShiftState { OFF, ON, LOCK }
-enum class Layer { LETTERS, SYMBOLS }
+enum class Layer { LETTERS, SYMBOLS, EMOJIS }
 
 /**
  * Toda la lógica de escritura. La palabra en curso vive como "texto en composición"
@@ -29,6 +31,8 @@ enum class Layer { LETTERS, SYMBOLS }
 class KeyboardController(
     private val host: ImeHost,
     private val engine: SuggestionEngine,
+    private val settings: TrazoSettings,
+    private val haptics: Haptics,
 ) {
 
     var shift by mutableStateOf(ShiftState.ON)
@@ -39,11 +43,14 @@ class KeyboardController(
         private set
 
     private val composing = StringBuilder()
+    // Última palabra cerrada: ancla de los bigramas para predecir la siguiente.
+    private var lastWord: String? = null
 
-    val keys get() = if (layer == Layer.LETTERS) Layouts.letters else Layouts.symbols
+    val keys get() = if (layer == Layer.SYMBOLS) Layouts.symbols else Layouts.letters
     val shifted get() = shift != ShiftState.OFF
 
     fun onChar(raw: Char) {
+        haptic()
         val out = if (shifted) raw.uppercaseChar() else raw
         if (layer == Layer.LETTERS && raw.isLetter()) {
             composing.append(out)
@@ -59,18 +66,23 @@ class KeyboardController(
     }
 
     fun onSpace() {
+        haptic()
         finishWord()
         val before = host.textBeforeCursor(2)
-        if (before.length == 2 && before[1] == ' ' && before[0].isLetterOrDigit()) {
+        if (settings.doubleSpacePeriod &&
+            before.length == 2 && before[1] == ' ' && before[0].isLetterOrDigit()
+        ) {
             host.backspace()
             host.commit(". ")
         } else {
             host.commit(" ")
         }
         syncAutoShift()
+        showNextWords()
     }
 
     fun onBackspace() {
+        haptic()
         if (composing.isNotEmpty()) {
             composing.deleteCharAt(composing.length - 1)
             if (composing.isEmpty()) {
@@ -82,17 +94,22 @@ class KeyboardController(
             }
         } else {
             host.backspace()
+            lastWord = null
+            suggestions = emptyList()
             syncAutoShift()
         }
     }
 
     fun onEnter() {
+        haptic()
         finishWord()
         host.newline()
+        lastWord = null
         syncAutoShift()
     }
 
     fun onShift() {
+        haptic()
         shift = when (shift) {
             ShiftState.OFF -> ShiftState.ON
             ShiftState.ON -> ShiftState.LOCK
@@ -100,28 +117,54 @@ class KeyboardController(
         }
     }
 
+    /** Alterna letras ↔ símbolos (desde emojis, vuelve a letras). */
     fun toggleLayer() {
+        haptic()
         finishWord()
         layer = if (layer == Layer.LETTERS) Layer.SYMBOLS else Layer.LETTERS
     }
 
-    /** Elige una sugerencia: reemplaza la palabra en composición y deja un espacio. */
+    fun showEmojis() {
+        haptic()
+        finishWord()
+        layer = Layer.EMOJIS
+    }
+
+    /** Inserta un emoji y lo recuerda como reciente. */
+    fun onEmoji(emoji: String) {
+        haptic()
+        finishWord()
+        host.commit(emoji)
+        settings.pushRecentEmoji(emoji)
+        lastWord = null
+        suggestions = emptyList()
+    }
+
+    /**
+     * Elige una sugerencia. Si hay palabra en composición, la reemplaza; si no, es una
+     * predicción de palabra siguiente y se inserta en el cursor. En ambos casos refuerza
+     * el bigrama y encadena nuevas predicciones.
+     */
     fun commitSuggestion(word: String) {
-        val cased = if (composing.firstOrNull()?.isUpperCase() == true) {
-            word.replaceFirstChar { it.uppercaseChar() }
-        } else {
-            word
-        }
-        host.commit("$cased ") // commitText reemplaza la región en composición
+        haptic()
+        val capitalize = composing.firstOrNull()?.isUpperCase() == true ||
+            (composing.isEmpty() && shifted)
+        val cased = if (capitalize) word.replaceFirstChar { it.uppercaseChar() } else word
+        host.commit("$cased ") // commitText reemplaza la región en composición si la hay
+        lastWord?.let { engine.learnBigram(it, word) }
         engine.learn(word)
+        lastWord = word.lowercase()
         composing.clear()
-        suggestions = emptyList()
         syncAutoShift()
+        showNextWords()
     }
 
     fun moveCursor(steps: Int) {
+        if (settings.haptics) haptics.tap(HapticLevel.SUAVE)
         if (composing.isNotEmpty()) finishWord()
         host.moveCursor(steps)
+        lastWord = null
+        suggestions = emptyList()
         syncAutoShift()
     }
 
@@ -129,6 +172,7 @@ class KeyboardController(
     fun reset() {
         composing.clear()
         suggestions = emptyList()
+        lastWord = null
     }
 
     /** El editor terminó la composición por su cuenta (cursor movido fuera, etc.). */
@@ -136,6 +180,7 @@ class KeyboardController(
         if (composing.isEmpty()) return
         composing.clear()
         suggestions = emptyList()
+        lastWord = null
     }
 
     fun hasComposing(): Boolean = composing.isNotEmpty()
@@ -146,16 +191,28 @@ class KeyboardController(
         shift = if (host.cursorCapsMode() != 0) ShiftState.ON else ShiftState.OFF
     }
 
+    private fun haptic() {
+        if (settings.haptics) haptics.tap(settings.hapticLevel)
+    }
+
     private fun finishWord() {
         if (composing.isNotEmpty()) {
             host.finishComposing()
-            engine.learn(composing.toString())
+            val word = composing.toString()
+            engine.learn(word)
+            lastWord?.let { engine.learnBigram(it, word) }
+            lastWord = word.lowercase().takeIf { it.length >= 2 && it.all { c -> c.isLetter() } }
             composing.clear()
         }
         suggestions = emptyList()
     }
 
+    private fun showNextWords() {
+        val prev = lastWord
+        suggestions = if (prev != null) engine.nextWords(prev, 3) else emptyList()
+    }
+
     private fun refreshSuggestions() {
-        suggestions = engine.suggest(composing.toString(), 3)
+        suggestions = engine.suggest(composing.toString(), settings.spanish, settings.english, 3)
     }
 }
diff --git a/keyboard/src/main/java/com/rizoma/trazo/model/Emoji.kt b/keyboard/src/main/java/com/rizoma/trazo/model/Emoji.kt
new file mode 100644
index 0000000..433c139
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/model/Emoji.kt
@@ -0,0 +1,44 @@
+package com.rizoma.trazo.model
+
+/** Catálogo curado de emojis por categoría para la capa de emojis de Trazo. */
+object Emojis {
+
+    val categories: List<Pair<String, List<String>>> = listOf(
+        "caras" to listOf(
+            "😀", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉",
+            "😊", "😇", "🥰", "😍", "😘", "😋", "😛", "🤪", "🤔", "🤨",
+            "😐", "😑", "😏", "😒", "🙄", "😔", "😴", "😌", "😬", "😳",
+            "🥺", "😢", "😭", "😤", "😠", "😎", "🤩", "🥳", "😱", "🤯",
+        ),
+        "gestos" to listOf(
+            "👍", "👎", "👌", "🤌", "✌️", "🤞", "🤟", "🤙", "👈", "👉",
+            "👆", "👇", "✋", "🤚", "👏", "🙌", "🤝", "🙏", "💪", "👀",
+            "🫶", "🤲", "☝️", "✊",
+        ),
+        "corazones" to listOf(
+            "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔",
+            "❣️", "💕", "💞", "💓", "💗", "💖", "✨", "💫", "⭐", "🌟",
+            "🔥", "💥", "💯", "🎉",
+        ),
+        "animales" to listOf(
+            "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯",
+            "🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🦄", "🐝", "🦋",
+            "🐢", "🐙", "🦀", "🐠",
+        ),
+        "comida" to listOf(
+            "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍒", "🍑",
+            "🥭", "🍍", "🥝", "🍅", "🥑", "🌽", "🌶️", "🍕", "🍔", "🍟",
+            "🌮", "🍣", "🍦", "☕", "🍺", "🍷",
+        ),
+        "objetos" to listOf(
+            "📱", "💻", "⌨️", "🖥️", "🎧", "📷", "🔋", "💡", "🔑", "🔒",
+            "✏️", "📌", "📎", "✂️", "📚", "💰", "🎁", "🎈", "🏆", "⚽",
+            "🎮", "🎯", "🚀", "🎵",
+        ),
+        "símbolos" to listOf(
+            "✅", "❌", "⭕", "❗", "❓", "💤", "🔴", "🟠", "🟡", "🟢",
+            "🔵", "🟣", "➕", "➖", "♻️", "⚡", "☀️", "🌙", "☁️", "🌈",
+            "💧", "❄️", "🌊", "💀",
+        ),
+    )
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ui/EmojiGrid.kt b/keyboard/src/main/java/com/rizoma/trazo/ui/EmojiGrid.kt
new file mode 100644
index 0000000..a3d6886
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/EmojiGrid.kt
@@ -0,0 +1,69 @@
+package com.rizoma.trazo.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyGridScope
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.trazo.model.Emojis
+import com.rizoma.trazo.ui.theme.Tenue
+
+/**
+ * Panel de emojis desplazable: primero "recientes" (si los hay), luego las categorías.
+ * Sustituye al grid 3x3 cuando la capa de emojis está activa.
+ */
+@Composable
+fun EmojiPad(
+    recents: List<String>,
+    onEmoji: (String) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    LazyVerticalGrid(
+        columns = GridCells.Fixed(8),
+        modifier = modifier.fillMaxWidth(),
+    ) {
+        if (recents.isNotEmpty()) {
+            header("recientes")
+            items(recents, key = { "r:$it" }) { EmojiCell(it, onEmoji) }
+        }
+        Emojis.categories.forEach { (name, list) ->
+            header(name)
+            items(list, key = { "$name:$it" }) { EmojiCell(it, onEmoji) }
+        }
+    }
+}
+
+private fun LazyGridScope.header(text: String) {
+    item(span = { GridItemSpan(maxLineSpan) }) {
+        Text(
+            text = text,
+            color = Tenue,
+            fontSize = 11.sp,
+            modifier = Modifier.padding(start = 10.dp, top = 10.dp, bottom = 2.dp),
+        )
+    }
+}
+
+@Composable
+private fun EmojiCell(emoji: String, onEmoji: (String) -> Unit) {
+    Box(
+        Modifier
+            .fillMaxWidth()
+            .height(42.dp)
+            .clickable { onEmoji(emoji) },
+        contentAlignment = Alignment.Center,
+    ) {
+        Text(text = emoji, fontSize = 24.sp)
+    }
+}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
index ea448e2..abbf561 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
@@ -28,6 +28,7 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import com.rizoma.trazo.data.TrazoSettings
 import com.rizoma.trazo.ime.KeyboardController
 import com.rizoma.trazo.ime.Layer
 import com.rizoma.trazo.ime.ShiftState
@@ -41,7 +42,7 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 
 @Composable
-fun KeyboardScreen(controller: KeyboardController) {
+fun KeyboardScreen(controller: KeyboardController, settings: TrazoSettings) {
     Column(
         Modifier
             .fillMaxWidth()
@@ -52,34 +53,48 @@ fun KeyboardScreen(controller: KeyboardController) {
     ) {
         SuggestionBar(controller.suggestions, onPick = controller::commitSuggestion)
 
-        val keys = controller.keys
-        for (row in 0 until 3) {
-            Row(Modifier.fillMaxWidth().height(70.dp)) {
-                for (col in 0 until 3) {
-                    KeyCell(
-                        key = keys[row * 3 + col],
-                        shifted = controller.shifted,
-                        onChar = controller::onChar,
-                        modifier = Modifier.weight(1f).fillMaxHeight(),
-                    )
+        if (controller.layer == Layer.EMOJIS) {
+            EmojiPad(
+                recents = settings.recentEmojis,
+                onEmoji = controller::onEmoji,
+                modifier = Modifier.fillMaxWidth().height(210.dp),
+            )
+            Row(Modifier.fillMaxWidth().height(52.dp)) {
+                FuncKey("ABC", NeonCian, Modifier.weight(1.4f), onClick = controller::toggleLayer)
+                SpaceKey(Modifier.weight(3f), onSpace = controller::onSpace, onCursor = controller::moveCursor)
+                BackspaceKey(Modifier.weight(1.4f), onBackspace = controller::onBackspace)
+            }
+        } else {
+            val keys = controller.keys
+            for (row in 0 until 3) {
+                Row(Modifier.fillMaxWidth().height(70.dp)) {
+                    for (col in 0 until 3) {
+                        KeyCell(
+                            key = keys[row * 3 + col],
+                            shifted = controller.shifted,
+                            onChar = controller::onChar,
+                            modifier = Modifier.weight(1f).fillMaxHeight(),
+                        )
+                    }
                 }
             }
-        }
 
-        Row(Modifier.fillMaxWidth().height(52.dp)) {
-            val shiftAccent = when (controller.shift) {
-                ShiftState.OFF -> Tenue
-                ShiftState.ON -> NeonVerde
-                ShiftState.LOCK -> NeonRosa
+            Row(Modifier.fillMaxWidth().height(52.dp)) {
+                val shiftAccent = when (controller.shift) {
+                    ShiftState.OFF -> Tenue
+                    ShiftState.ON -> NeonVerde
+                    ShiftState.LOCK -> NeonRosa
+                }
+                FuncKey("⇧", shiftAccent, Modifier.weight(1f), onClick = controller::onShift)
+                FuncKey(
+                    if (controller.layer == Layer.LETTERS) "?123" else "ABC",
+                    NeonCian, Modifier.weight(1f), onClick = controller::toggleLayer,
+                )
+                FuncKey("😊", NeonRosa, Modifier.weight(1f), onClick = controller::showEmojis)
+                SpaceKey(Modifier.weight(3f), onSpace = controller::onSpace, onCursor = controller::moveCursor)
+                BackspaceKey(Modifier.weight(1f), onBackspace = controller::onBackspace)
+                FuncKey("↵", NeonVerde, Modifier.weight(1f), onClick = controller::onEnter)
             }
-            FuncKey("⇧", shiftAccent, Modifier.weight(1f), onClick = controller::onShift)
-            FuncKey(
-                if (controller.layer == Layer.LETTERS) "?123" else "ABC",
-                NeonCian, Modifier.weight(1f), onClick = controller::toggleLayer,
-            )
-            SpaceKey(Modifier.weight(3f), onSpace = controller::onSpace, onCursor = controller::moveCursor)
-            BackspaceKey(Modifier.weight(1f), onBackspace = controller::onBackspace)
-            FuncKey("↵", NeonVerde, Modifier.weight(1f), onClick = controller::onEnter)
         }
     }
 }