Trazo: arreglar cursor al reposicionar + mayúsculas en R↑ y columna reordenada
onUpdateSelection ahora detecta el toque del usuario y finaliza la composición en su sitio (antes el siguiente carácter saltaba a la posición vieja). Las mayúsculas pasan al swipe ↑ de la R; la columna derecha queda emoji / ?123 / borrar / enter y el espacio ocupa toda la fila inferior. 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.
5 qillqakuna · +161 −62
- keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt +9 −5
- keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt +13 −4
- keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt +86 −28
- keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt +17 −1
- keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt +36 −24
diff --git a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
index 536c387..c3bddeb 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/TrazoImeService.kt
@@ -144,11 +144,15 @@ class TrazoImeService : InputMethodService(), LifecycleOwner, ViewModelStoreOwne
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()
- }
+ if (!controller.hasComposing()) return
+ // Tras NUESTRA propia edición, el caret queda justo al final de la región
+ // en composición (sin selección). Cualquier otra cosa —un toque en el
+ // texto, una selección, o que el editor cerrara la composición
+ // (candidatesEnd == -1)— significa que el cursor se movió por el usuario:
+ // cerramos la palabra para que lo siguiente caiga donde tocó.
+ val nuestraEdicion = newSelStart == newSelEnd &&
+ candidatesEnd != -1 && newSelEnd == candidatesEnd
+ if (!nuestraEdicion) controller.finishComposingForCursorMove()
}
override fun onFinishInputView(finishingInput: Boolean) {
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 7e630e9..23fad00 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ime/KeyboardController.kt
@@ -175,12 +175,21 @@ class KeyboardController(
lastWord = null
}
- /** El editor terminó la composición por su cuenta (cursor movido fuera, etc.). */
- fun abandonComposing() {
- if (composing.isEmpty()) return
- composing.clear()
+ /**
+ * El cursor se movió por acción del usuario (un toque en el texto) con una
+ * palabra a medio componer. Finalizamos la composición EN SU SITIO con
+ * [ImeHost.finishComposing]: si no, el editor conserva la región vieja y el
+ * siguiente carácter la reemplazaría, devolviendo el cursor a la posición
+ * anterior. Tras cerrarla, lo que se escriba cae donde tocó el usuario.
+ */
+ fun finishComposingForCursorMove() {
+ if (composing.isNotEmpty()) {
+ host.finishComposing()
+ composing.clear()
+ }
suggestions = emptyList()
lastWord = null
+ syncAutoShift()
}
fun hasComposing(): Boolean = composing.isNotEmpty()
diff --git a/keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt b/keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt
index 4d1756c..9730fe7 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/model/KeyLayout.kt
@@ -13,6 +13,8 @@ enum class Dir { C, N, NE, E, SE, S, SW, W, NW }
data class KeyDef(
val center: Char,
val swipes: Map<Dir, Char> = emptyMap(),
+ /** Dirección de swipe que activa mayúsculas en vez de emitir carácter (la R↑). */
+ val shiftDir: Dir? = null,
) {
fun charFor(dir: Dir): Char? = if (dir == Dir.C) center else swipes[dir]
}
@@ -20,52 +22,108 @@ data class KeyDef(
object Layouts {
/**
- * Letras — layout MessagEase AUTÉNTICO (inglés), tomado de la fuente de Thumb-Key
- * (sucesor FOSS de MessagEase). Toques A N I / H O R / T E S; las secundarias en su
- * dirección de trazo original (V = A↘ hacia O, D = O↓, C = O←, etc.).
- * Añadido para español: Ñ con trazo ↑ en la N. Puntuación frecuente en la E.
+ * Letras — layout MessagEase ESPAÑOL AUTÉNTICO, mapeado 1:1 desde el teclado
+ * oficial de Exideas (Spanish). Toques A N I / D O R / T E S.
+ * · Acentos en swipe sobre su vocal: á(A↙) í(I↑) é(E←) ó(T↓) ú(T↑).
+ * · ñ en N↑, l en N↓. ü y ç en la tecla D. v en A↘.
+ * · O es el hub: q u p / c · b / g h j (la h, antes tecla propia, va en O↓).
+ * · Puntuación frecuente en la E. La R lleva mayús (shift) con trazo ↑ en la UI.
+ *
+ * Overlay de símbolos en modo letra: cada dirección SIN letra recibe el mismo símbolo
+ * que esa posición de tecla tiene en [symbols] (espejo posicional → el símbolo queda en
+ * el mismo sitio en ambas capas). Los que chocan con una letra viven solo en ?123.
+ * Excepción español: ¿ ¡ chocan con x/l, así que se reubican juntos en la tecla I.
*/
val letters: List<KeyDef> = listOf(
- // fila superior: A N I
- KeyDef('a', mapOf(Dir.SE to 'v')),
- KeyDef('n', mapOf(Dir.S to 'l', Dir.N to 'ñ')),
- KeyDef('i', mapOf(Dir.SW to 'x')),
- // fila media: H O R
- KeyDef('h', mapOf(Dir.E to 'k')),
+ // fila superior: A N I (símbolos espejo de 1 2 3)
+ KeyDef('a', mapOf(Dir.S to 'á', Dir.SE to 'v', Dir.SW to '$', Dir.E to '-')),
+ KeyDef(
+ 'n',
+ mapOf(
+ Dir.N to 'ñ', Dir.S to 'l',
+ Dir.NW to '`', Dir.NE to '´', Dir.W to '+', Dir.E to '!', Dir.SW to '/', Dir.SE to '\\',
+ ),
+ ),
+ KeyDef(
+ 'i',
+ mapOf(
+ Dir.NW to 'í', Dir.SW to 'x',
+ Dir.W to '?', Dir.S to '=', Dir.SE to '€', Dir.E to '¿', Dir.NE to '¡',
+ ),
+ ),
+ // fila media: D O R (símbolos espejo de 4 5 6)
+ KeyDef(
+ 'd',
+ mapOf(
+ Dir.N to 'ü', Dir.E to 'k', Dir.S to 'ç',
+ Dir.NW to '{', Dir.NE to '%', Dir.W to '(', Dir.SW to '[', Dir.SE to '_',
+ ),
+ ),
KeyDef(
'o',
mapOf(
Dir.NW to 'q', Dir.N to 'u', Dir.NE to 'p', Dir.E to 'b',
- Dir.SE to 'j', Dir.S to 'd', Dir.SW to 'g', Dir.W to 'c',
+ Dir.SE to 'j', Dir.S to 'h', Dir.SW to 'g', Dir.W to 'c',
+ ),
+ ),
+ KeyDef(
+ 'r',
+ mapOf(
+ Dir.W to 'm',
+ Dir.NW to '|', Dir.NE to '}', Dir.E to ')', Dir.SW to '@', Dir.SE to ']',
+ ),
+ shiftDir = Dir.N, // R↑ = mayúsculas (OFF→ON→LOCK)
+ ),
+ // fila inferior: T E S (símbolos espejo de 7 8 9)
+ KeyDef(
+ 't',
+ mapOf(
+ Dir.N to 'ú', Dir.NE to 'y', Dir.S to 'ó',
+ Dir.NW to '~', Dir.W to '<', Dir.E to '*', Dir.SE to '\t',
),
),
- KeyDef('r', mapOf(Dir.W to 'm')),
- // fila inferior: T E S
- KeyDef('t', mapOf(Dir.NE to 'y')),
KeyDef(
'e',
mapOf(
- Dir.N to 'w', Dir.NE to '\'', Dir.E to 'z',
- Dir.SE to ':', Dir.S to '.', Dir.SW to ',',
+ Dir.N to 'w', Dir.W to 'é', Dir.E to 'z',
+ Dir.NE to '\'', Dir.SE to ':', Dir.S to '.', Dir.SW to ',', Dir.NW to '"',
+ ),
+ ),
+ KeyDef(
+ 's',
+ mapOf(
+ Dir.NW to 'f', Dir.N to '&',
+ Dir.NE to '°', Dir.W to '#', Dir.E to '>', Dir.SW to ';',
),
),
- KeyDef('s', mapOf(Dir.NW to 'f')),
)
/**
- * Números y símbolos. Dígitos 1-9 en posición de marcador telefónico; 0 y la
- * puntuación frecuente como swipes.
+ * Números y símbolos — capa MessagEase ESPAÑOL AUTÉNTICA (captura oficial de Exideas).
+ * Dígitos 1-9 en marcador telefónico; cada símbolo en su dirección de trazo original.
+ * Desviaciones respecto a la captura (esta app no tiene celda 0 dedicada como MessagEase):
+ * · 0 → 5↓ (la tecla 5 va libre; toca 5 y desliza abajo).
+ * · ¿ ¡ no salen en la captura pero el español los exige: ¡ en 2↓, ¿ en 3↙.
*/
val symbols: List<KeyDef> = listOf(
- KeyDef('1', mapOf(Dir.N to '!', Dir.E to '@')),
- KeyDef('2', mapOf(Dir.N to '"', Dir.E to '#')),
- KeyDef('3', mapOf(Dir.N to '\'', Dir.W to '$')),
- KeyDef('4', mapOf(Dir.N to '%', Dir.E to '&')),
- KeyDef('5', mapOf(Dir.N to '*', Dir.E to '+', Dir.W to '-', Dir.S to '=')),
- KeyDef('6', mapOf(Dir.N to '/', Dir.W to '\\')),
- KeyDef('7', mapOf(Dir.N to '(', Dir.E to ')')),
- KeyDef('8', mapOf(Dir.N to ',', Dir.E to '.', Dir.W to '?')),
- KeyDef('9', mapOf(Dir.N to ';', Dir.W to ':', Dir.E to '0')),
+ // fila 1: 1 2 3
+ KeyDef('1', mapOf(Dir.E to '-', Dir.SW to '$')),
+ KeyDef(
+ '2',
+ mapOf(
+ Dir.NW to '`', Dir.N to '^', Dir.NE to '´', Dir.W to '+',
+ Dir.E to '!', Dir.S to '¡', Dir.SW to '/', Dir.SE to '\\',
+ ),
+ ),
+ KeyDef('3', mapOf(Dir.W to '?', Dir.SW to '¿', Dir.S to '=', Dir.SE to '€')),
+ // fila 2: 4 5 6
+ KeyDef('4', mapOf(Dir.NW to '{', Dir.NE to '%', Dir.W to '(', Dir.SW to '[', Dir.SE to '_')),
+ KeyDef('5', mapOf(Dir.S to '0')),
+ KeyDef('6', mapOf(Dir.NW to '|', Dir.NE to '}', Dir.E to ')', Dir.SW to '@', Dir.SE to ']')),
+ // fila 3: 7 8 9
+ KeyDef('7', mapOf(Dir.NW to '~', Dir.N to '¨', Dir.W to '<', Dir.E to '*', Dir.SE to '\t')),
+ KeyDef('8', mapOf(Dir.NW to '"', Dir.NE to '\'', Dir.SW to ',', Dir.S to '.', Dir.SE to ':')),
+ KeyDef('9', mapOf(Dir.N to '&', Dir.NE to '°', Dir.W to '#', Dir.E to '>', Dir.SW to ';')),
)
}
diff --git a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt
index b59bbaf..2c654a6 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyCell.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
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.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
@@ -41,6 +42,8 @@ fun KeyCell(
key: KeyDef,
shifted: Boolean,
onChar: (Char) -> Unit,
+ onShift: () -> Unit = {},
+ shiftAccent: Color = Tenue,
modifier: Modifier = Modifier,
) {
val tapRadiusPx = with(LocalDensity.current) { 22.dp.toPx() }
@@ -65,7 +68,8 @@ fun KeyCell(
val dx = current.x - down.position.x
val dy = current.y - down.position.y
val dir = directionOf(dx, dy, tapRadiusPx)
- key.charFor(dir)?.let { raw -> onChar(raw) }
+ if (key.shiftDir != null && dir == key.shiftDir) onShift()
+ else key.charFor(dir)?.let { raw -> onChar(raw) }
}
},
) {
@@ -79,6 +83,18 @@ fun KeyCell(
modifier = Modifier.align(align).padding(horizontal = 6.dp, vertical = 3.dp),
)
}
+ // Pista de mayúsculas (R↑): coloreada según el estado del shift.
+ key.shiftDir?.let { d ->
+ hintAlignments[d]?.let { align ->
+ Text(
+ text = "⇧",
+ color = shiftAccent,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.align(align).padding(horizontal = 6.dp, vertical = 3.dp),
+ )
+ }
+ }
// Carácter central.
Text(
text = glyph(key.center, shifted),
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 abbf561..4801628 100644
--- a/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
+++ b/keyboard/src/main/java/com/rizoma/trazo/ui/KeyboardScreen.kt
@@ -66,34 +66,46 @@ fun KeyboardScreen(controller: KeyboardController, settings: TrazoSettings) {
}
} else {
val keys = controller.keys
- for (row in 0 until 3) {
- Row(Modifier.fillMaxWidth().height(70.dp)) {
- for (col in 0 until 3) {
- KeyCell(
- key = keys[row * 3 + col],
- shifted = controller.shifted,
- onChar = controller::onChar,
- modifier = Modifier.weight(1f).fillMaxHeight(),
- )
+ // Color de la pista de mayúsculas (R↑) según el estado del shift.
+ val shiftAccent = when (controller.shift) {
+ ShiftState.OFF -> Tenue
+ ShiftState.ON -> NeonVerde
+ ShiftState.LOCK -> NeonRosa
+ }
+ // Grid 3×3 (izquierda, ~¾) + columna de funciones (derecha, ~¼), como MessagEase.
+ Row(Modifier.fillMaxWidth().height(210.dp)) {
+ Column(Modifier.weight(3f).fillMaxHeight()) {
+ for (row in 0 until 3) {
+ Row(Modifier.fillMaxWidth().weight(1f)) {
+ for (col in 0 until 3) {
+ KeyCell(
+ key = keys[row * 3 + col],
+ shifted = controller.shifted,
+ onChar = controller::onChar,
+ onShift = controller::onShift,
+ shiftAccent = shiftAccent,
+ modifier = Modifier.weight(1f).fillMaxHeight(),
+ )
+ }
+ }
}
}
+ // Columna derecha, arriba→abajo: emoticons, núm/alfa, borrar, enter.
+ // (Mayúsculas ya no va aquí: vive en el swipe ↑ de la R.)
+ Column(Modifier.weight(1f).fillMaxHeight()) {
+ FuncKey("😊", NeonRosa, Modifier.fillMaxWidth().weight(1f), onClick = controller::showEmojis)
+ FuncKey(
+ if (controller.layer == Layer.LETTERS) "?123" else "ABC",
+ NeonCian, Modifier.fillMaxWidth().weight(1f), onClick = controller::toggleLayer,
+ )
+ BackspaceKey(Modifier.fillMaxWidth().weight(1f), onBackspace = controller::onBackspace)
+ FuncKey("↵", NeonVerde, Modifier.fillMaxWidth().weight(1f), onClick = controller::onEnter)
+ }
}
- Row(Modifier.fillMaxWidth().height(52.dp)) {
- val shiftAccent = when (controller.shift) {
- ShiftState.OFF -> Tenue
- ShiftState.ON -> NeonVerde
- ShiftState.LOCK -> NeonRosa
- }
- FuncKey("⇧", shiftAccent, Modifier.weight(1f), onClick = controller::onShift)
- FuncKey(
- if (controller.layer == Layer.LETTERS) "?123" else "ABC",
- NeonCian, Modifier.weight(1f), onClick = controller::toggleLayer,
- )
- FuncKey("😊", NeonRosa, Modifier.weight(1f), onClick = controller::showEmojis)
- 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)
+ // Fila inferior: barra de espacio a todo el ancho.
+ Row(Modifier.fillMaxWidth().height(56.dp)) {
+ SpaceKey(Modifier.weight(1f), onSpace = controller::onSpace, onCursor = controller::moveCursor)
}
}
}