gioser

sergio/humanoid

HTML 13TOML 10 Definitions

Trazo: predicción, aprendizaje y gestos de espacio/borrado (v0.2)

- Barra de sugerencias: la palabra en curso vive como texto en composición
  (subrayado); un toque la reemplaza por la sugerencia y deja espacio.
- SuggestionEngine: diccionario base en español (assets/dict_es.txt) + palabras
  aprendidas y persistidas (DataStore), predicción por prefijo ponderada.
- Mantener ⌫ → borrado repetido; swipe horizontal en la barra espaciadora
  mueve el cursor (toque = espacio).
- Controller reescrito sobre setComposingText/finishComposingText; abandona la
  composición si el cursor sale (onUpdateSelection).

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

8380e44a0947bafb169773353ce2b2036aff9e70 · by sergio · (4 months ago) · parent e3dd167a1 · browse the tree at this commit

Semantic delta

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

6 files · +597 −27

diff --git a/keyboard/build.gradle.kts b/keyboard/build.gradle.kts
index c39522e..357e53c 100644
--- a/keyboard/build.gradle.kts
+++ b/keyboard/build.gradle.kts
@@ -44,4 +44,6 @@ dependencies {
     implementation(libs.androidx.compose.foundation)
     implementation(libs.androidx.compose.animation)
     implementation(libs.androidx.compose.material3)
+    implementation(libs.androidx.datastore.preferences)
+    implementation(libs.kotlinx.coroutines.android)
 }
diff --git a/keyboard/src/main/assets/dict_es.txt b/keyboard/src/main/assets/dict_es.txt
new file mode 100644
index 0000000..8734a82
--- /dev/null
+++ b/keyboard/src/main/assets/dict_es.txt
@@ -0,0 +1,253 @@
+que
+de
+no
+la
+el
+es
+en
+un
+una
+por
+con
+para
+los
+las
+del
+se
+su
+sus
+lo
+le
+les
+me
+te
+nos
+mi
+tu
+si
+sí
+ya
+muy
+más
+pero
+porque
+como
+cuando
+donde
+quien
+cual
+todo
+toda
+todos
+todas
+este
+esta
+esto
+estos
+estas
+ese
+esa
+eso
+aquel
+algo
+alguien
+nada
+nadie
+cada
+otro
+otra
+otros
+otras
+mismo
+misma
+también
+tampoco
+solo
+sólo
+entre
+hasta
+desde
+sobre
+sin
+bajo
+ante
+tras
+hacia
+durante
+según
+contra
+ahora
+antes
+después
+luego
+siempre
+nunca
+mientras
+aunque
+entonces
+así
+bien
+mal
+mejor
+peor
+mucho
+mucha
+poco
+poca
+tanto
+tanta
+demasiado
+bastante
+casi
+quizá
+tal
+vez
+hola
+gracias
+favor
+buenos
+buenas
+días
+día
+tarde
+noche
+mañana
+hoy
+ayer
+año
+años
+mes
+meses
+semana
+hora
+horas
+tiempo
+momento
+vida
+mundo
+casa
+familia
+amigo
+amiga
+amigos
+gente
+persona
+personas
+hombre
+mujer
+niño
+niña
+padre
+madre
+hijo
+hija
+trabajo
+trabajar
+estudiar
+escuela
+ciudad
+país
+calle
+agua
+comida
+dinero
+nombre
+número
+cosa
+cosas
+parte
+forma
+manera
+lugar
+caso
+ejemplo
+problema
+pregunta
+respuesta
+idea
+verdad
+razón
+ser
+estar
+haber
+tener
+hacer
+poder
+decir
+ir
+ver
+dar
+saber
+querer
+llegar
+pasar
+deber
+poner
+parecer
+quedar
+creer
+hablar
+llevar
+dejar
+seguir
+encontrar
+llamar
+venir
+pensar
+salir
+volver
+tomar
+conocer
+vivir
+sentir
+mirar
+contar
+empezar
+esperar
+buscar
+existir
+entrar
+trabajar
+escribir
+perder
+producir
+recordar
+español
+señor
+señora
+favor
+disculpe
+perdón
+claro
+vale
+genial
+bueno
+mañana
+nosotros
+vosotros
+ellos
+ellas
+usted
+ustedes
+algún
+alguna
+ningún
+ninguna
+cualquier
+primero
+segundo
+tercero
+último
+nuevo
+nueva
+grande
+pequeño
+mejor
+importante
+posible
+necesario
+diferente
+fácil
+difícil
+feliz
+contento
diff --git a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
index 8a17584..07ef2c7 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
@@ -17,10 +17,16 @@ import androidx.savedstate.SavedStateRegistry
 import androidx.savedstate.SavedStateRegistryController
 import androidx.savedstate.SavedStateRegistryOwner
 import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.rizoma.trazo.data.SuggestionEngine
 import com.rizoma.trazo.ime.ImeHost
 import com.rizoma.trazo.ime.KeyboardController
 import com.rizoma.trazo.ui.KeyboardScreen
 import com.rizoma.trazo.ui.theme.TrazoTheme
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlin.math.abs
 
 /**
  * Trazo IME. Hospeda un teclado Compose dentro del InputMethodService, para lo cual
@@ -37,6 +43,8 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
     private val savedStateController = SavedStateRegistryController.create(this)
     override val savedStateRegistry: SavedStateRegistry get() = savedStateController.savedStateRegistry
 
+    private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
+
     private var editorInputType: Int = InputType.TYPE_CLASS_TEXT
 
     private val host = object : ImeHost {
@@ -51,11 +59,10 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
         }
 
         override fun newline() {
-            val action = currentInputEditorInfo?.imeOptions ?: 0
-            val opt = action and EditorInfo.IME_MASK_ACTION
-            if (opt != EditorInfo.IME_ACTION_NONE && opt != EditorInfo.IME_ACTION_UNSPECIFIED &&
-                (action and EditorInfo.IME_FLAG_NO_ENTER_ACTION) == 0
-            ) {
+            val info = currentInputEditorInfo
+            val opt = (info?.imeOptions ?: 0) and EditorInfo.IME_MASK_ACTION
+            val noEnterAction = ((info?.imeOptions ?: 0) and EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0
+            if (!noEnterAction && opt != EditorInfo.IME_ACTION_NONE && opt != EditorInfo.IME_ACTION_UNSPECIFIED) {
                 currentInputConnection?.performEditorAction(opt)
             } else {
                 currentInputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
@@ -63,6 +70,23 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
             }
         }
 
+        override fun setComposing(text: String) {
+            currentInputConnection?.setComposingText(text, 1)
+        }
+
+        override fun finishComposing() {
+            currentInputConnection?.finishComposingText()
+        }
+
+        override fun moveCursor(steps: Int) {
+            val ic = currentInputConnection ?: return
+            val code = if (steps > 0) KeyEvent.KEYCODE_DPAD_RIGHT else KeyEvent.KEYCODE_DPAD_LEFT
+            repeat(abs(steps)) {
+                ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, code))
+                ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, code))
+            }
+        }
+
         override fun textBeforeCursor(n: Int): CharSequence =
             currentInputConnection?.getTextBeforeCursor(n, 0) ?: ""
 
@@ -70,16 +94,19 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
             currentInputConnection?.getCursorCapsMode(editorInputType) ?: 0
     }
 
-    private val controller = KeyboardController(host)
+    private lateinit var engine: SuggestionEngine
+    private lateinit var controller: KeyboardController
 
     override fun onCreate() {
         super.onCreate()
         savedStateController.performRestore(null)
+        engine = SuggestionEngine(this, scope)
+        controller = KeyboardController(host, engine)
         lifecycleRegistry.currentState = Lifecycle.State.CREATED
     }
 
     override fun onCreateInputView(): View {
-        val view = ComposeView(this).apply {
+        return ComposeView(this).apply {
             setViewTreeLifecycleOwner(this@TrazoImeService)
             setViewTreeViewModelStoreOwner(this@TrazoImeService)
             setViewTreeSavedStateRegistryOwner(this@TrazoImeService)
@@ -89,14 +116,27 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
                 }
             }
         }
-        return view
     }
 
     override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
         super.onStartInputView(info, restarting)
         editorInputType = info?.inputType ?: InputType.TYPE_CLASS_TEXT
-        lifecycleRegistry.currentState = Lifecycle.State.RESUMED
+        controller.reset()
         controller.syncAutoShift()
+        lifecycleRegistry.currentState = Lifecycle.State.RESUMED
+    }
+
+    override fun onUpdateSelection(
+        oldSelStart: Int, oldSelEnd: Int,
+        newSelStart: Int, newSelEnd: Int,
+        candidatesStart: Int, candidatesEnd: Int,
+    ) {
+        super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd)
+        // Si el editor ya no tiene región en composición pero nosotros creíamos tenerla,
+        // significa que el cursor se movió fuera: abandonamos la palabra local.
+        if (candidatesStart == -1 && controller.hasComposing()) {
+            controller.abandonComposing()
+        }
     }
 
     override fun onFinishInputView(finishingInput: Boolean) {
@@ -107,6 +147,7 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
     override fun onDestroy() {
         lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
         store.clear()
+        scope.cancel()
         super.onDestroy()
     }
 }
diff --git a/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt b/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
new file mode 100644
index 0000000..1d25124
--- /dev/null
+++ b/keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
@@ -0,0 +1,92 @@
+package com.rizoma.trazo.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+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.
+ */
+class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
+
+    private val appContext = context.applicationContext
+    private val store = appContext.trazoLearnedStore
+    private val freq = ConcurrentHashMap<String, Int>()
+
+    init {
+        scope.launch(Dispatchers.IO) {
+            loadBase()
+            loadLearned()
+        }
+    }
+
+    private fun loadBase() {
+        runCatching {
+            appContext.assets.open("dict_es.txt").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) }
+                        i++
+                    }
+                }
+            }
+        }
+    }
+
+    private suspend fun loadLearned() {
+        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 }
+                }
+            }
+        }
+    }
+
+    /** Completa el prefijo con las palabras de mayor peso (más cortas primero a igual peso). */
+    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) }
+            .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. */
+    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 }
+        scope.launch(Dispatchers.IO) {
+            runCatching {
+                store.edit { prefs ->
+                    val key = intPreferencesKey(W + w)
+                    prefs[key] = (prefs[key] ?: 0) + 1
+                }
+            }
+        }
+    }
+
+    private companion object {
+        const val BASE_TOP = 5000
+        const val LEARN_INC = 400
+        const val W = "w:"
+    }
+}
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 73c3e5e..02eabeb 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
@@ -3,6 +3,7 @@ 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.SuggestionEngine
 import com.rizoma.trazo.model.Layouts
 
 /** Puente con el editor real. Lo implementa el InputMethodService. */
@@ -10,6 +11,9 @@ interface ImeHost {
     fun commit(text: String)
     fun backspace()
     fun newline()
+    fun setComposing(text: String)
+    fun finishComposing()
+    fun moveCursor(steps: Int)
     fun textBeforeCursor(n: Int): CharSequence
     /** TextUtils.CAP_MODE_* combinado: !=0 si el editor quiere mayúscula aquí. */
     fun cursorCapsMode(): Int
@@ -19,27 +23,43 @@ enum class ShiftState { OFF, ON, LOCK }
 enum class Layer { LETTERS, SYMBOLS }
 
 /**
- * Toda la lógica de escritura. La UI solo dispara eventos; el estado de mayúscula
- * y de capa son observables por Compose.
+ * Toda la lógica de escritura. La palabra en curso vive como "texto en composición"
+ * del editor (subrayada) para poder reemplazarla por una sugerencia de un toque.
  */
-class KeyboardController(private val host: ImeHost) {
+class KeyboardController(
+    private val host: ImeHost,
+    private val engine: SuggestionEngine,
+) {
 
     var shift by mutableStateOf(ShiftState.ON)
         private set
     var layer by mutableStateOf(Layer.LETTERS)
         private set
+    var suggestions by mutableStateOf<List<String>>(emptyList())
+        private set
+
+    private val composing = StringBuilder()
 
     val keys get() = if (layer == Layer.LETTERS) Layouts.letters else Layouts.symbols
     val shifted get() = shift != ShiftState.OFF
 
     fun onChar(raw: Char) {
         val out = if (shifted) raw.uppercaseChar() else raw
-        host.commit(out.toString())
-        if (shift == ShiftState.ON) shift = ShiftState.OFF // mayúscula de un solo disparo
+        if (layer == Layer.LETTERS && raw.isLetter()) {
+            composing.append(out)
+            if (shift == ShiftState.ON) shift = ShiftState.OFF
+            host.setComposing(composing.toString())
+            refreshSuggestions()
+        } else {
+            finishWord()
+            host.commit(out.toString())
+            if (shift == ShiftState.ON) shift = ShiftState.OFF
+            syncAutoShift()
+        }
     }
 
     fun onSpace() {
-        // Doble espacio tras palabra → ". " (puntua y capitaliza).
+        finishWord()
         val before = host.textBeforeCursor(2)
         if (before.length == 2 && before[1] == ' ' && before[0].isLetterOrDigit()) {
             host.backspace()
@@ -51,11 +71,23 @@ class KeyboardController(private val host: ImeHost) {
     }
 
     fun onBackspace() {
-        host.backspace()
-        syncAutoShift()
+        if (composing.isNotEmpty()) {
+            composing.deleteCharAt(composing.length - 1)
+            if (composing.isEmpty()) {
+                host.finishComposing()
+                suggestions = emptyList()
+            } else {
+                host.setComposing(composing.toString())
+                refreshSuggestions()
+            }
+        } else {
+            host.backspace()
+            syncAutoShift()
+        }
     }
 
     fun onEnter() {
+        finishWord()
         host.newline()
         syncAutoShift()
     }
@@ -69,12 +101,61 @@ class KeyboardController(private val host: ImeHost) {
     }
 
     fun toggleLayer() {
+        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 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
+        engine.learn(word)
+        composing.clear()
+        suggestions = emptyList()
+        syncAutoShift()
+    }
+
+    fun moveCursor(steps: Int) {
+        if (composing.isNotEmpty()) finishWord()
+        host.moveCursor(steps)
+        syncAutoShift()
+    }
+
+    /** Nuevo campo de entrada: descarta cualquier composición local. */
+    fun reset() {
+        composing.clear()
+        suggestions = emptyList()
+    }
+
+    /** El editor terminó la composición por su cuenta (cursor movido fuera, etc.). */
+    fun abandonComposing() {
+        if (composing.isEmpty()) return
+        composing.clear()
+        suggestions = emptyList()
+    }
+
+    fun hasComposing(): Boolean = composing.isNotEmpty()
+
     /** Recalcula la mayúscula automática según el contexto del editor. */
     fun syncAutoShift() {
         if (shift == ShiftState.LOCK) return
         shift = if (host.cursorCapsMode() != 0) ShiftState.ON else ShiftState.OFF
     }
+
+    private fun finishWord() {
+        if (composing.isNotEmpty()) {
+            host.finishComposing()
+            engine.learn(composing.toString())
+            composing.clear()
+        }
+        suggestions = emptyList()
+    }
+
+    private fun refreshSuggestions() {
+        suggestions = engine.suggest(composing.toString(), 3)
+    }
 }
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 ab8f9a4..bfdcf54 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
@@ -2,6 +2,9 @@ package com.rizoma.trazo.ui
 
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
@@ -12,11 +15,16 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
 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.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.positionChange
+import androidx.compose.ui.platform.LocalDensity
 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.ime.KeyboardController
@@ -28,6 +36,8 @@ import com.rizoma.trazo.ui.theme.NeonVerde
 import com.rizoma.trazo.ui.theme.Tecla
 import com.rizoma.trazo.ui.theme.Tenue
 import com.rizoma.trazo.ui.theme.Vacio
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
 
 @Composable
 fun KeyboardScreen(controller: KeyboardController) {
@@ -37,8 +47,9 @@ fun KeyboardScreen(controller: KeyboardController) {
             .background(Vacio)
             .padding(4.dp),
     ) {
+        SuggestionBar(controller.suggestions, onPick = controller::commitSuggestion)
+
         val keys = controller.keys
-        // 3 filas × 3 teclas.
         for (row in 0 until 3) {
             Row(Modifier.fillMaxWidth().height(70.dp)) {
                 for (col in 0 until 3) {
@@ -52,7 +63,6 @@ fun KeyboardScreen(controller: KeyboardController) {
             }
         }
 
-        // Fila de funciones.
         Row(Modifier.fillMaxWidth().height(52.dp)) {
             val shiftAccent = when (controller.shift) {
                 ShiftState.OFF -> Tenue
@@ -64,20 +74,43 @@ fun KeyboardScreen(controller: KeyboardController) {
                 if (controller.layer == Layer.LETTERS) "?123" else "ABC",
                 NeonCian, Modifier.weight(1f), onClick = controller::toggleLayer,
             )
-            FuncKey("espacio", Color.White, Modifier.weight(3f), onClick = controller::onSpace)
-            FuncKey("⌫", Color.White, Modifier.weight(1f), onClick = controller::onBackspace)
+            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)
         }
     }
 }
 
 @Composable
-private fun FuncKey(
-    label: String,
-    accent: Color,
-    modifier: Modifier,
-    onClick: () -> Unit,
-) {
+private fun SuggestionBar(suggestions: List<String>, onPick: (String) -> Unit) {
+    Row(
+        Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 4.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        suggestions.forEach { word ->
+            Box(
+                Modifier
+                    .weight(1f)
+                    .fillMaxHeight()
+                    .padding(3.dp)
+                    .clip(RoundedCornerShape(10.dp))
+                    .clickable { onPick(word) },
+                contentAlignment = Alignment.Center,
+            ) {
+                Text(
+                    text = word,
+                    color = NeonVerde,
+                    fontSize = 15.sp,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun FuncKey(label: String, accent: Color, modifier: Modifier, onClick: () -> Unit) {
     Box(
         modifier
             .fillMaxHeight()
@@ -95,3 +128,71 @@ private fun FuncKey(
         )
     }
 }
+
+/** Espacio con swipe horizontal para mover el cursor; toque = espacio. */
+@Composable
+private fun SpaceKey(modifier: Modifier, onSpace: () -> Unit, onCursor: (Int) -> Unit) {
+    val step = with(LocalDensity.current) { 16.dp.toPx() }
+    Box(
+        modifier
+            .fillMaxHeight()
+            .padding(3.dp)
+            .clip(RoundedCornerShape(14.dp))
+            .background(Tecla)
+            .pointerInput(Unit) {
+                awaitEachGesture {
+                    val down = awaitFirstDown()
+                    var accum = 0f
+                    var moved = false
+                    while (true) {
+                        val event = awaitPointerEvent()
+                        val change = event.changes.firstOrNull { it.id == down.id } ?: break
+                        accum += change.positionChange().x
+                        change.consume()
+                        while (accum >= step) { onCursor(1); accum -= step; moved = true }
+                        while (accum <= -step) { onCursor(-1); accum += step; moved = true }
+                        if (!change.pressed) break
+                    }
+                    if (!moved) onSpace()
+                }
+            },
+        contentAlignment = Alignment.Center,
+    ) {
+        Text("espacio", color = Tenue, fontSize = 14.sp)
+    }
+}
+
+/** Borrado con repetición al mantener pulsado. */
+@Composable
+private fun BackspaceKey(modifier: Modifier, onBackspace: () -> Unit) {
+    val scope = rememberCoroutineScope()
+    Box(
+        modifier
+            .fillMaxHeight()
+            .padding(3.dp)
+            .clip(RoundedCornerShape(14.dp))
+            .background(Tecla)
+            .pointerInput(Unit) {
+                detectTapGestures(
+                    onPress = {
+                        onBackspace()
+                        val job = scope.launch {
+                            delay(380)
+                            while (true) {
+                                onBackspace()
+                                delay(55)
+                            }
+                        }
+                        try {
+                            awaitRelease()
+                        } finally {
+                            job.cancel()
+                        }
+                    },
+                )
+            },
+        contentAlignment = Alignment.Center,
+    ) {
+        Text("⌫", color = Color.White, fontSize = 20.sp)
+    }
+}