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. */ 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 } enum class ShiftState { OFF, ON, LOCK } enum class Layer { LETTERS, SYMBOLS, EMOJIS } /** * 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, private val engine: SuggestionEngine, private val settings: TrazoSettings, private val haptics: Haptics, ) { var shift by mutableStateOf(ShiftState.ON) private set var layer by mutableStateOf(Layer.LETTERS) private set var suggestions by mutableStateOf>(emptyList()) 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.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) 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() { haptic() finishWord() val before = host.textBeforeCursor(2) 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()) { host.finishComposing() suggestions = emptyList() } else { host.setComposing(composing.toString()) refreshSuggestions() } } 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 ShiftState.LOCK -> ShiftState.OFF } } /** Alterna letras ↔ símbolos (desde emojis, vuelve a letras). */ fun toggleLayer() { haptic() finishWord() layer = if (layer == Layer.LETTERS) Layer.SYMBOLS else Layer.LETTERS } 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) { 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() 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() } /** Nuevo campo de entrada: descarta cualquier composición local. */ fun reset() { composing.clear() suggestions = emptyList() lastWord = null } /** El editor terminó la composición por su cuenta (cursor movido fuera, etc.). */ fun abandonComposing() { if (composing.isEmpty()) return composing.clear() suggestions = emptyList() lastWord = null } 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 haptic() { if (settings.haptics) haptics.tap(settings.hapticLevel) } private fun finishWord() { if (composing.isNotEmpty()) { host.finishComposing() 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(), settings.spanish, settings.english, 3) } }