⎇ 5227fbf1abfb2a437968ebc63692560c3a929230
keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
History
raw
Trazo: arreglar cursor al reposicionar + mayúsculas en R↑ y columna reordenada
1package com.rizoma.trazo.ime
2
3import androidx.compose.runtime.getValue
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.setValue
6import com.rizoma.trazo.data.HapticLevel
7import com.rizoma.trazo.data.SuggestionEngine
8import com.rizoma.trazo.data.TrazoSettings
9import com.rizoma.trazo.model.Layouts
10
11/** Puente con el editor real. Lo implementa el InputMethodService. */
12interface ImeHost {
13 fun commit(text: String)
14 fun backspace()
15 fun newline()
16 fun setComposing(text: String)
17 fun finishComposing()
18 fun moveCursor(steps: Int)
19 fun textBeforeCursor(n: Int): CharSequence
20 /** TextUtils.CAP_MODE_* combinado: !=0 si el editor quiere mayúscula aquí. */
21 fun cursorCapsMode(): Int
22}
23
24enum class ShiftState { OFF, ON, LOCK }
25enum class Layer { LETTERS, SYMBOLS, EMOJIS }
26
27/**
28 * Toda la lógica de escritura. La palabra en curso vive como "texto en composición"
29 * del editor (subrayada) para poder reemplazarla por una sugerencia de un toque.
30 */
31class KeyboardController(
32 private val host: ImeHost,
33 private val engine: SuggestionEngine,
34 private val settings: TrazoSettings,
35 private val haptics: Haptics,
36) {
37
38 var shift by mutableStateOf(ShiftState.ON)
39 private set
40 var layer by mutableStateOf(Layer.LETTERS)
41 private set
42 var suggestions by mutableStateOf<List<String>>(emptyList())
43 private set
44
45 private val composing = StringBuilder()
46 // Última palabra cerrada: ancla de los bigramas para predecir la siguiente.
47 private var lastWord: String? = null
48
49 val keys get() = if (layer == Layer.SYMBOLS) Layouts.symbols else Layouts.letters
50 val shifted get() = shift != ShiftState.OFF
51
52 fun onChar(raw: Char) {
53 haptic()
54 val out = if (shifted) raw.uppercaseChar() else raw
55 if (layer == Layer.LETTERS && raw.isLetter()) {
56 composing.append(out)
57 if (shift == ShiftState.ON) shift = ShiftState.OFF
58 host.setComposing(composing.toString())
59 refreshSuggestions()
60 } else {
61 finishWord()
62 host.commit(out.toString())
63 if (shift == ShiftState.ON) shift = ShiftState.OFF
64 syncAutoShift()
65 }
66 }
67
68 fun onSpace() {
69 haptic()
70 finishWord()
71 val before = host.textBeforeCursor(2)
72 if (settings.doubleSpacePeriod &&
73 before.length == 2 && before[1] == ' ' && before[0].isLetterOrDigit()
74 ) {
75 host.backspace()
76 host.commit(". ")
77 } else {
78 host.commit(" ")
79 }
80 syncAutoShift()
81 showNextWords()
82 }
83
84 fun onBackspace() {
85 haptic()
86 if (composing.isNotEmpty()) {
87 composing.deleteCharAt(composing.length - 1)
88 if (composing.isEmpty()) {
89 host.finishComposing()
90 suggestions = emptyList()
91 } else {
92 host.setComposing(composing.toString())
93 refreshSuggestions()
94 }
95 } else {
96 host.backspace()
97 lastWord = null
98 suggestions = emptyList()
99 syncAutoShift()
100 }
101 }
102
103 fun onEnter() {
104 haptic()
105 finishWord()
106 host.newline()
107 lastWord = null
108 syncAutoShift()
109 }
110
111 fun onShift() {
112 haptic()
113 shift = when (shift) {
114 ShiftState.OFF -> ShiftState.ON
115 ShiftState.ON -> ShiftState.LOCK
116 ShiftState.LOCK -> ShiftState.OFF
117 }
118 }
119
120 /** Alterna letras ↔ símbolos (desde emojis, vuelve a letras). */
121 fun toggleLayer() {
122 haptic()
123 finishWord()
124 layer = if (layer == Layer.LETTERS) Layer.SYMBOLS else Layer.LETTERS
125 }
126
127 fun showEmojis() {
128 haptic()
129 finishWord()
130 layer = Layer.EMOJIS
131 }
132
133 /** Inserta un emoji y lo recuerda como reciente. */
134 fun onEmoji(emoji: String) {
135 haptic()
136 finishWord()
137 host.commit(emoji)
138 settings.pushRecentEmoji(emoji)
139 lastWord = null
140 suggestions = emptyList()
141 }
142
143 /**
144 * Elige una sugerencia. Si hay palabra en composición, la reemplaza; si no, es una
145 * predicción de palabra siguiente y se inserta en el cursor. En ambos casos refuerza
146 * el bigrama y encadena nuevas predicciones.
147 */
148 fun commitSuggestion(word: String) {
149 haptic()
150 val capitalize = composing.firstOrNull()?.isUpperCase() == true ||
151 (composing.isEmpty() && shifted)
152 val cased = if (capitalize) word.replaceFirstChar { it.uppercaseChar() } else word
153 host.commit("$cased ") // commitText reemplaza la región en composición si la hay
154 lastWord?.let { engine.learnBigram(it, word) }
155 engine.learn(word)
156 lastWord = word.lowercase()
157 composing.clear()
158 syncAutoShift()
159 showNextWords()
160 }
161
162 fun moveCursor(steps: Int) {
163 if (settings.haptics) haptics.tap(HapticLevel.SUAVE)
164 if (composing.isNotEmpty()) finishWord()
165 host.moveCursor(steps)
166 lastWord = null
167 suggestions = emptyList()
168 syncAutoShift()
169 }
170
171 /** Nuevo campo de entrada: descarta cualquier composición local. */
172 fun reset() {
173 composing.clear()
174 suggestions = emptyList()
175 lastWord = null
176 }
177
178 /**
179 * El cursor se movió por acción del usuario (un toque en el texto) con una
180 * palabra a medio componer. Finalizamos la composición EN SU SITIO con
181 * [ImeHost.finishComposing]: si no, el editor conserva la región vieja y el
182 * siguiente carácter la reemplazaría, devolviendo el cursor a la posición
183 * anterior. Tras cerrarla, lo que se escriba cae donde tocó el usuario.
184 */
185 fun finishComposingForCursorMove() {
186 if (composing.isNotEmpty()) {
187 host.finishComposing()
188 composing.clear()
189 }
190 suggestions = emptyList()
191 lastWord = null
192 syncAutoShift()
193 }
194
195 fun hasComposing(): Boolean = composing.isNotEmpty()
196
197 /** Recalcula la mayúscula automática según el contexto del editor. */
198 fun syncAutoShift() {
199 if (shift == ShiftState.LOCK) return
200 shift = if (host.cursorCapsMode() != 0) ShiftState.ON else ShiftState.OFF
201 }
202
203 private fun haptic() {
204 if (settings.haptics) haptics.tap(settings.hapticLevel)
205 }
206
207 private fun finishWord() {
208 if (composing.isNotEmpty()) {
209 host.finishComposing()
210 val word = composing.toString()
211 engine.learn(word)
212 lastWord?.let { engine.learnBigram(it, word) }
213 lastWord = word.lowercase().takeIf { it.length >= 2 && it.all { c -> c.isLetter() } }
214 composing.clear()
215 }
216 suggestions = emptyList()
217 }
218
219 private fun showNextWords() {
220 val prev = lastWord
221 suggestions = if (prev != null) engine.nextWords(prev, 3) else emptyList()
222 }
223
224 private fun refreshSuggestions() {
225 suggestions = engine.suggest(composing.toString(), settings.spanish, settings.english, 3)
226 }
227}