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>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
6 qillqakuna · +597 −27
- keyboard/build.gradle.kts +2 −0
- keyboard/src/main/assets/dict_es.txt +253 −0
- keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt +50 −9
- keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt +92 −0
- keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt +89 −8
- keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt +111 −10
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)
+ }
+}