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. */ 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 } /** * 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, ) { 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() 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 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() { finishWord() val before = host.textBeforeCursor(2) if (before.length == 2 && before[1] == ' ' && before[0].isLetterOrDigit()) { host.backspace() host.commit(". ") } else { host.commit(" ") } syncAutoShift() } fun onBackspace() { 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() } fun onShift() { shift = when (shift) { ShiftState.OFF -> ShiftState.ON ShiftState.ON -> ShiftState.LOCK ShiftState.LOCK -> ShiftState.OFF } } 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) } }